Suspend Voice response controls during Nexus Traitor return

This commit is contained in:
Quintine committed 2026-09-27 20:16:10 +10:00
1 parent 8956cf0be7
commit 0d01d18ddd
3 files changed
+86 -37

No files matched your search

+43 -33
View File
@@ -2205,37 +2205,45 @@ export function GameTable({
g.response.kind !== 'faceDancerReplacement' && (
<p className="notice">{g.response.intent}</p>
)}
{me.hand
?.filter((c) => g.responseControls?.cancelCards.includes(c.id))
.map((c) => (
<Button
key={c.id}
className="game-action"
disabled={busy}
onClick={() =>
act({ type: 'card', card: c.id, mode: 'cancel' })
}
>
{c.kind === 'worthless'
? `Use ${c.name} as Karama`
: 'Cancel with Karama'}
</Button>
))}
{(g.responseControls?.cancelCards.length ||
(g.nexusCards && (
(g.response.kind === 'eliteStrength' &&
g.players.some((player) => player.id === g.response?.owner && player.faction === 'emperor')) ||
(g.response.kind === 'voice' &&
g.players.some((player) => player.id === g.response?.owner && player.faction === 'beneGesserit'))
))) &&
!g.responseControls?.hasPassed ? (
actionButton('Allow this power', { type: 'passResponse' })
) : (
<p className="muted">
{g.responseControls?.hasPassed
? 'You have allowed this power. Waiting for the response to finish.'
: 'Waiting for the response to finish.'}
{g.nexusTraitors?.pending ? (
<p className="notice">
Finish the private Nexus Traitor card return before responding to this power.
</p>
) : (
<>
{me.hand
?.filter((c) => g.responseControls?.cancelCards.includes(c.id))
.map((c) => (
<Button
key={c.id}
className="game-action"
disabled={busy}
onClick={() =>
act({ type: 'card', card: c.id, mode: 'cancel' })
}
>
{c.kind === 'worthless'
? `Use ${c.name} as Karama`
: 'Cancel with Karama'}
</Button>
))}
{(g.responseControls?.cancelCards.length ||
(g.nexusCards && (
(g.response.kind === 'eliteStrength' &&
g.players.some((player) => player.id === g.response?.owner && player.faction === 'emperor')) ||
(g.response.kind === 'voice' &&
g.players.some((player) => player.id === g.response?.owner && player.faction === 'beneGesserit'))
))) &&
!g.responseControls?.hasPassed ? (
actionButton('Allow this power', { type: 'passResponse' })
) : (
<p className="muted">
{g.responseControls?.hasPassed
? 'You have allowed this power. Waiting for the response to finish.'
: 'Waiting for the response to finish.'}
</p>
)}
</>
)}
</>
) : g.decision ? (
@@ -5027,9 +5035,11 @@ export function GameTable({
: richeseCardActionBlock(c)) ??
(canUseAsKaramaRole(g, me, c)
? g.response
? !g.responseControls?.cancelCards.includes(c.id)
? 'This card cannot cancel the current power.'
: null
? g.nexusTraitors?.pending
? 'Finish the private Nexus Traitor card return before responding to this power.'
: !g.responseControls?.cancelCards.includes(c.id)
? 'This card cannot cancel the current power.'
: null
: g.decision
? 'Finish the pending decision before taking an auction card.'
: g.phase !== 3 || !g.auction || g.richeseAuction
+4 -2
View File
@@ -50,8 +50,10 @@ removes only the Voice; any already selected Prescience preparation and
unchanged plan promises continue. The spent-card battle receipt, private
holder offer and exact response survive JSON and room recovery; other seats
see the public response but not who holds the card. Bot profiles play or pass
their own reaction. An active Truthtrance or unfinished private Harkonnen
Nexus Traitor return suspends the offer until that interaction finishes.
their own reaction. An active Truthtrance suspends the private offer. An
unfinished Harkonnen Nexus Traitor return suspends both the offer and Voice
response actions, including the hand-card Karama cancellation, until the
private card return finishes.
This does not grant absent-BG Secret Ally Voice or change Cunning's advisor
conversion. Basic and Advanced retain their native Voice legality; the
+39 -2
View File
@@ -1,6 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { applyAction, viewGame, type GameView } from '../game/engine';
@@ -8,15 +9,27 @@ import { nexusBgBetrayalFixture } from './fixture-nexus-bg-betrayal';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier, context);
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map((match) => [match[1], match[1]]));
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
},
});
// NexusCards imports next/image; the test-only alias must exist before module loading.
// Install Next and CSS-module shims before loading the UI components.
const { NexusCards } = await import('../components/nexus-cards');
const { GameTable } = await import('../components/game-table');
function markup(view: GameView, busy = false) {
return renderToStaticMarkup(createElement(NexusCards, { game: view, act() {}, busy }));
}
function table(view: GameView) {
return renderToStaticMarkup(createElement(GameTable, {
game: view, send: async () => {}, onExit() {}, busy: false,
}));
}
void test('private BG holder alone sees an enabled Voice prevention control', () => {
const initial = nexusBgBetrayalFixture();
@@ -29,4 +42,28 @@ void test('private BG holder alone sees an enabled Voice prevention control', ()
assert.match(markup(viewGame(g, 'r'), true), /disabled=""[^>]*>Prevent Voice with Betrayal/);
});
void test('a private Traitor return suspends Voice pass and Karama controls until completion', () => {
let g = applyAction(nexusBgBetrayalFixture('r', false, 'harkonnen'),
'p', { type: 'voice', kind: 'poison', must: false });
const karamaIndex = g.deck.findIndex(card => card.effect === 'karama');
assert.ok(karamaIndex >= 0);
const karama = g.deck.splice(karamaIndex, 1)[0];
g.players.find(player => player.id === 'r')!.hand.push(karama);
assert.match(table(viewGame(g, 'r')), /Allow this power/);
assert.match(table(viewGame(g, 'r')), /Cancel with Karama/);
const offer = viewGame(g, 'q').nexusTraitors!.offer!;
g = applyAction(g, 'q', { type: 'nexusTraitorDraw', event: offer.event, mode: offer.mode });
assert.throws(() => applyAction(g, 'r', { type: 'passResponse' }));
assert.doesNotMatch(table(viewGame(g, 'r')), /Allow this power/);
assert.doesNotMatch(table(viewGame(g, 'r')), /Cancel with Karama/);
assert.match(table(viewGame(g, 'r')), /disabled=""[^>]*>Cancel this power<\/button>/);
const pending = viewGame(g, 'q').nexusTraitors!.pending!;
g = applyAction(g, 'q', {
type: 'nexusTraitorReturn', event: pending.event,
cards: pending.choices.filter(card => card.drawn).map(card => card.id),
});
assert.match(table(viewGame(g, 'r')), /Allow this power/);
assert.match(table(viewGame(g, 'r')), /Cancel with Karama/);
});
aliases.deregister();