From 0d01d18dddf2d76a13da004ce715b088e3abe33e Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:16:10 +1000 Subject: [PATCH] Suspend Voice response controls during Nexus Traitor return --- components/game-table.tsx | 76 +++++++++++++---------- docs/NEXUS_BENE_GESSERIT_RULES.md | 6 +- tests/nexus-bg-betrayal-controls.test.tsx | 41 +++++++++++- 3 files changed, 86 insertions(+), 37 deletions(-) diff --git a/components/game-table.tsx b/components/game-table.tsx index 4c7d3567..b5cebc11 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -2205,37 +2205,45 @@ export function GameTable({ g.response.kind !== 'faceDancerReplacement' && (

{g.response.intent}

)} - {me.hand - ?.filter((c) => g.responseControls?.cancelCards.includes(c.id)) - .map((c) => ( - - ))} - {(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' }) - ) : ( -

- {g.responseControls?.hasPassed - ? 'You have allowed this power. Waiting for the response to finish.' - : 'Waiting for the response to finish.'} + {g.nexusTraitors?.pending ? ( +

+ Finish the private Nexus Traitor card return before responding to this power.

+ ) : ( + <> + {me.hand + ?.filter((c) => g.responseControls?.cancelCards.includes(c.id)) + .map((c) => ( + + ))} + {(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' }) + ) : ( +

+ {g.responseControls?.hasPassed + ? 'You have allowed this power. Waiting for the response to finish.' + : 'Waiting for the response to finish.'} +

+ )} + )} ) : 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 diff --git a/docs/NEXUS_BENE_GESSERIT_RULES.md b/docs/NEXUS_BENE_GESSERIT_RULES.md index c1ea9baf..85fc2d46 100644 --- a/docs/NEXUS_BENE_GESSERIT_RULES.md +++ b/docs/NEXUS_BENE_GESSERIT_RULES.md @@ -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 diff --git a/tests/nexus-bg-betrayal-controls.test.tsx b/tests/nexus-bg-betrayal-controls.test.tsx index e79fbf2e..26b637cd 100644 --- a/tests/nexus-bg-betrayal-controls.test.tsx +++ b/tests/nexus-bg-betrayal-controls.test.tsx @@ -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();