From 8956cf0be7e03a603584a7dcd4a12d9aa4001795 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:57:26 +1000 Subject: [PATCH] Prototype Bene Gesserit Nexus Voice betrayal response --- components/game-table.tsx | 16 ++- components/nexus-cards.tsx | 13 +++ docs/NEXUS_BENE_GESSERIT_RULES.md | 22 +++- game/bots.ts | 10 +- game/engine.ts | 66 ++++++++++- game/reference.ts | 3 +- tests/fixture-nexus-bg-betrayal.ts | 39 +++++++ tests/nexus-bg-betrayal-controls.test.tsx | 32 ++++++ tests/nexus-bg-betrayal-recovery.test.ts | 61 ++++++++++ tests/nexus-bg-betrayal.test.ts | 133 ++++++++++++++++++++++ 10 files changed, 384 insertions(+), 11 deletions(-) create mode 100644 tests/fixture-nexus-bg-betrayal.ts create mode 100644 tests/nexus-bg-betrayal-controls.test.tsx create mode 100644 tests/nexus-bg-betrayal-recovery.test.ts create mode 100644 tests/nexus-bg-betrayal.test.ts diff --git a/components/game-table.tsx b/components/game-table.tsx index 1a67d2ed..4c7d3567 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -2163,6 +2163,14 @@ export function GameTable({ A private Emperor Nexus Betrayal may suppress it without moving starred forces.

)} + {g.response.kind === 'voice' && + g.nexusCards && + g.players.some((player) => player.id === g.response?.owner && player.faction === 'beneGesserit') && ( +

+ Every seat may respond to this Voice before plans are sealed. + A private Bene Gesserit Nexus Betrayal may prevent it. +

+ )} {g.response.kind === 'revivalIncome' && g.response.amount !== undefined && (

@@ -2214,8 +2222,12 @@ export function GameTable({ ))} {(g.responseControls?.cancelCards.length || - (g.response.kind === 'eliteStrength' && g.nexusCards && - g.players.some((player) => player.id === g.response?.owner && player.faction === 'emperor'))) && + (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' }) ) : ( diff --git a/components/nexus-cards.tsx b/components/nexus-cards.tsx index ba5ce751..9652680a 100644 --- a/components/nexus-cards.tsx +++ b/components/nexus-cards.tsx @@ -158,6 +158,19 @@ export function NexusCards({ game, act, busy }: { game: GameView; act: (action: )} + {game.nexusBgBetrayal && offer.card === 'beneGesserit' && ( +

+

Bene Gesserit Betrayal · Voice

+

+ Spend your Nexus card to prevent this declared Voice before battle + plans are sealed. Prescience and other battle powers still proceed. +

+ +
+ )}
diff --git a/docs/NEXUS_BENE_GESSERIT_RULES.md b/docs/NEXUS_BENE_GESSERIT_RULES.md index 9d8a526c..c1ea9baf 100644 --- a/docs/NEXUS_BENE_GESSERIT_RULES.md +++ b/docs/NEXUS_BENE_GESSERIT_RULES.md @@ -39,6 +39,24 @@ These are implementation consequences, not additional publisher quotations: - Secret Ally supplies one Voice in one owned battle and spends the card. It does not grant Voice in another faction's battle or future battles. Reuse native command classification, compliance, compulsory-card logic and existing promise validation; do not expose whether the opponent privately holds a matching card. - Bind Voice to its original battle, owner, target and command. Close the opportunity before either Prescience or sealed plans make the command late. Native and Nexus inspections must share that boundary; this is consistent timing composition for Atreides Secret Ally, not a separately printed FAQ answer. +## Bounded Betrayal Voice reaction + +The implemented native-Voice reaction opens the existing `voice` response to +every seat whenever Nexus cards are enabled, even when nobody has Karama or +the Bene Gesserit card. This fixed public timing does not identify a secret +holder. An unallied non-BG holder may spend that one physical card against a +current BG Voice before plans are sealed. The native combat response quote +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. + +This does not grant absent-BG Secret Ally Voice or change Cunning's advisor +conversion. Basic and Advanced retain their native Voice legality; the +optional Nexus module still cannot start as a complete public mode. + ## Material boundaries requiring a ruling No retrieved primary clarification resolves these specific combinations. Do not silently broaden permission or describe a conservative block as the printed rule. @@ -47,10 +65,10 @@ No retrieved primary clarification resolves these specific combinations. Do not 2. **Storm:** does Cunning allow an in-place flip in a storm-locked stronghold? The FAQ's prohibition explicitly addresses the earlier ordinary Battle flip; its rationale concerns inability to battle. Applying it to this later card is plausible, not explicit. 3. **Borrowed Voice and Karama:** the card expressly grants a named advantage, while the normal native owner is absent. E3's secret-alliance terminology and the general alliance-cancellation rule support cancellation; absence of a native BG owner supports the opposite reading used for Atreides' differently worded effect. No retrieved Nexus FAQ selects one. Do not copy that earlier implementation choice as authority. -The existing Betrayal response-privacy question remains pending; this audit adds no duplicate user question. It likewise does not settle historical Homeworld occupation ownership. Ordinary unoccupied, older, storm-free advisor groups are a clear selection subset, but broader claims must retain the boundaries above. +The remaining private-reaction policy question applies to other hidden effects; the bounded Voice Betrayal path uses a uniform native response. This audit likewise does not settle historical Homeworld occupation ownership. Ordinary unoccupied, older, storm-free advisor groups are a clear selection subset, but broader claims retain the boundaries above. ## Suggested state and verification contract Keep stance choices public and event-bound: owner, turn, own-action identity, selected territory IDs, original counts/stances and settled/canceled result. A retry must not flip twice or consume a second card. For borrowed Voice, keep source and receipt separate from native ability ownership, with cancellation behavior dependent on the unresolved ruling. -Exercise multi-territory and multi-sector selections, current-owner timing, conserved counters, Basic absence, occupied strongholds, No-Field getter traps, stale selection and saved continuations. Voice tests should cover generic and named special commands, impossible compliance, prior inspection, sealed plans, private hand permutation and bot actions using only projected choices. No runtime was changed by this audit. +Exercise multi-territory and multi-sector selections, current-owner timing, conserved counters, Basic absence, occupied strongholds, No-Field getter traps, stale selection and saved continuations. Borrowed Voice still needs generic and named special-command, impossible-compliance, prior-inspection, sealed-plan and private-hand-permutation checks before implementation. diff --git a/game/bots.ts b/game/bots.ts index 0e0868a5..a0eecf18 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -1422,6 +1422,8 @@ function policyActions(g: GameView): Action[] { } if (g.nexusEmperorBetrayal) return [{ type: 'nexusEmperorBetrayal', event: g.nexusEmperorBetrayal.event }]; + if (g.nexusBgBetrayal) + return [{ type: 'nexusBgBetrayal', event: g.nexusBgBetrayal.event }]; if (g.response) { const benefitEnemy = g.response.owner !== me.id && g.response.owner !== me.ally; @@ -1473,9 +1475,11 @@ function policyActions(g: GameView): Action[] { g.response.intent === 'double')))) ) return [{ type: 'card', card: card.id, mode: 'cancel' }]; - if (g.response.kind === 'eliteStrength' && g.nexusCards && - g.players.some((player) => player.id === g.response!.owner && player.faction === 'emperor') && - !g.responseControls?.hasPassed) + if (g.nexusCards && !g.responseControls?.hasPassed && + ((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')))) return [{ type: 'passResponse' }]; return !g.responseControls?.cancelCards.length || g.responseControls.hasPassed diff --git a/game/engine.ts b/game/engine.ts index a5239b73..4804f617 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1468,6 +1468,9 @@ export type Game = { nexusEcazBetrayalHistory?: { snapshot: EcazBetrayalSnapshot; stage: 'pending' | 'complete' }[]; /** One actual Sardaukar battle advantage suppressed by a spent Emperor card. */ nexusEmperorBetrayalHistory?: { event: string; turn: number; territory: string; owner: string; target: string }[]; + /** Original native Voice canceled by a spent Bene Gesserit Nexus card. */ + nexusBgBetrayalHistory?: { event: string; turn: number; territory: string; owner: string; + target: string; voice: NonNullable }[]; nexusFaceDancerHistory?: NexusFaceDancerReceipt[]; nexusSuboidHistory?: NexusSuboidReceipt[]; nexusSuboidLast?: { event: string; owner: string; turn: number }; @@ -1762,12 +1765,12 @@ function emperorBetrayalWindow(g: Game) { return !!(g.nexusCards?.cards && g.status === 'playing' && g.phase === 6 && battle && !battle.revealed && battle.event && response?.kind === 'eliteStrength' && response.owner === byFaction(g, 'emperor')?.id && - [battle.attacker, battle.defender].includes(response.owner) && + (response.owner === battle.attacker || response.owner === battle.defender) && !battle.eliteBlocked?.includes(response.owner)); } function nexusEmperorBetrayalOffer(g: Game, owner: string) { const player = g.players.find((p) => p.id === owner); - if (g.truthtrance || !emperorBetrayalWindow(g) || !player || player.ally || + if (g.truthtrance || pendingNexusTraitors(g) || !emperorBetrayalWindow(g) || !player || player.ally || owner === g.response!.owner || g.response!.passed.includes(owner) || g.nexusCards!.cards!.hands[owner] !== 'emperor') return null; return { event: g.battle!.event!, target: g.response!.owner }; @@ -1811,6 +1814,60 @@ function playNexusEmperorBetrayal(g: Game, p: Player, action: Action) { log(g, `${p.name} spent Emperor Nexus Betrayal to suppress ${getPlayer(g, record.target).name}'s Sardaukar strength in this battle. Actual starred counters remain Sardaukar.`, { faction: p.faction, name: 'Emperor Nexus Betrayal' }); } +function bgBetrayalWindow(g: Game) { + const response = g.response, battle = g.battle; + return !!(g.nexusCards?.cards && g.status === 'playing' && g.phase === 6 && + battle && !battle.revealed && battle.event && battle.voice && + response?.kind === 'voice' && response.owner === byFaction(g, 'beneGesserit')?.id && + (battle.voice.target === battle.attacker || battle.voice.target === battle.defender)); +} +function nexusBgBetrayalOffer(g: Game, owner: string) { + const player = g.players.find(p => p.id === owner); + if (g.truthtrance || pendingNexusTraitors(g) || !bgBetrayalWindow(g) || !player || player.ally || + owner === g.response!.owner || g.response!.passed.includes(owner) || + g.nexusCards!.cards!.hands[owner] !== 'beneGesserit') return null; + return { event: g.battle!.event!, target: g.response!.owner }; +} +function nexusBgBetrayalIntegrity(g: Game) { + const history = g.nexusBgBetrayalHistory; + if (history === undefined) return; + requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0, + 'Bene Gesserit Betrayal has lost its physical card module.'); + const events = new Set(); + for (const record of history) { + requireRule(record && typeof record.event === 'string' && record.event.length > 0 && + Number.isInteger(record.turn) && record.turn > 0 && record.turn <= g.turn && + typeof record.territory === 'string' && record.territory.length > 0 && + record.owner !== record.target && + g.players.some(p => p.id === record.owner) && + g.players.some(p => p.id === record.target && p.faction === 'beneGesserit') && + record.voice && typeof record.voice.must === 'boolean' && + VOICE_KINDS.includes(record.voice.kind) && + g.players.some(p => p.id === record.voice.target) && !events.has(record.event), + 'Bene Gesserit Betrayal has a malformed or repeated Voice receipt.'); + events.add(record.event); + } + const last = history.at(-1)!; + if (last.turn === g.turn) requireRule(g.nexusCards.cards.discard.includes('beneGesserit'), + 'Bene Gesserit Betrayal has lost its spent physical card.'); + if (g.battle?.event === last.event) requireRule(g.battle.territory === last.territory && + !g.battle.voice, + 'The current battle has regained its canceled Voice.'); +} +function playNexusBgBetrayal(g: Game, p: Player, action: Action) { + const offer = nexusBgBetrayalOffer(g, p.id); + requireRule(offer && action.event === offer.event && + Object.keys(action).sort().join(',') === 'event,type', + 'Choose your current Bene Gesserit Voice response.'); + const record = { event: offer.event, turn: g.turn, territory: g.battle!.territory, + owner: p.id, target: g.response!.owner, voice: structuredClone(g.battle!.voice!) }; + g.nexusCards!.cards = nexusRule(() => + discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); + (g.nexusBgBetrayalHistory ??= []).push(record); + finishResponse(g, true); + log(g, `${p.name} spent Bene Gesserit Nexus Betrayal to prevent ${getPlayer(g, record.target).name}'s Voice in this battle. The battle and any later Prescience remain available.`, + { faction: p.faction, name: 'Bene Gesserit Nexus Betrayal' }); +} function nexusEmperorSecretAllyOffer(g:Game,owner:string) { const p = g.players.find(p => p.id === owner); if (!p || g.nexusCards?.cards?.hands[owner] !== 'emperor' || byFaction(g,'emperor')) return null; @@ -14875,6 +14932,7 @@ function marketGholaIntegrity(g: Game) { nexusCardsIntegrity(g); nexusEmperorSecretIntegrity(g); nexusEmperorBetrayalIntegrity(g); + nexusBgBetrayalIntegrity(g); nexusFremenRevivalIntegrity(g); nexusFremenCunningIntegrity(g); nexusEcazBetrayalIntegrity(g); @@ -21374,7 +21432,7 @@ function settleAutomaticContinuations(g: Game) { g.players.some( (p) => !response.passed.includes(p.id) && - (emperorBetrayalWindow(g) || + (emperorBetrayalWindow(g) || bgBetrayalWindow(g) || responseCancelCards(g, p, response).length > 0), ) ) @@ -21536,6 +21594,7 @@ function applyActionInner( if (t === 'nexusChoamTrade') { playNexusChoamTrade(g, p, action); return g; } if (t === 'nexusEmperorRevive') { playNexusEmperorRevive(g,p,action); return g; } if (t === 'nexusEmperorBetrayal') { playNexusEmperorBetrayal(g,p,action); return g; } + if (t === 'nexusBgBetrayal') { playNexusBgBetrayal(g,p,action); return g; } if (t === 'nexusFremenRevive') { playNexusFremenRevive(g,p,action); return g; } if (t === 'nexusMoritaniBetrayal') { playMoritaniBetrayal(g, p, action); return g; } if (t === 'nexusEcazBetrayal') { playEcazBetrayal(g, p, action); return g; } @@ -25453,6 +25512,7 @@ export function viewGame(state: Game, id: string) { nexusChoamTrade: currentNexusChoamTrade(g, id), nexusEmperorSecretAlly: nexusEmperorSecretAllyOffer(g,id), nexusEmperorBetrayal: nexusEmperorBetrayalOffer(g, id), + nexusBgBetrayal: nexusBgBetrayalOffer(g, id), nexusFremenRevival: fremenNexusRevivalOffer(g,id), nexusMoritani: nexusMoritaniOffer(g,id), nexusMoritaniBetrayal: moritaniBetrayalOffer(g,id, diff --git a/game/reference.ts b/game/reference.ts index 35b0c3b3..c3195549 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4006,6 +4006,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'In Advanced play, Bene Gesserit Cunning converts selected whole advisor territories during your own Shipment and Movement action. All your counters in each selected territory become fighters together across sectors. It spends the Nexus card once, with no spice, shipment or movement cost.', 'One Karama response covers the entire declared territory set. Cancellation keeps every selected group as advisors and leaves the Nexus card spent. Existing territory restrictions still apply; fresh accompanied advisors and storm-related conversion remain unavailable pending their rulings.', 'Bene Gesserit Secret Ally borrowed Voice remains unavailable pending its cancellation and timing interpretation. Its printed permission does not make the other unfinished effects available.', + 'Bene Gesserit Betrayal can prevent the native Voice while the ordinary pre-plan response is open. In Nexus games every seat responds even without a canceling card, so the window does not reveal the hidden holder. An unallied opponent spends one physical Bene Gesserit Nexus card to remove only that declared Voice; Prescience and plan commitments continue. Truthtrance has priority. This path does not grant borrowed Voice.', 'Emperor Cunning requires an Advanced battle with at least five ordinary Emperor counters and no actual Sardaukar, before submitting your plan. The played Nexus card opens one Karama response. Allowing it makes five ordinary counters count as Sardaukar for this battle; cancellation prevents the entire enhancement and leaves the Nexus card spent.', 'Temporary Sardaukar use the applicable strength and support rules, including the Fremen exception, but remain physically ordinary counters. Their losses go to Tanks as ordinary counters, without changing starred reserves or Tanks. Existing inspected or promised plans remain binding. The role ends after this battle; fewer-than-five groups remain unavailable. The separate Secret Ally revival has its own connected controls; Emperor purchase modes remain unfinished.', 'Emperor Betrayal has a battle alternative in Advanced play: an unallied opponent holding the Emperor Nexus card can spend it during the ordinary pre-plan Sardaukar response to suppress actual Sardaukar strength for this battle. Every seat receives the same response opportunity even with no card, keeping hidden custody private. The starred pieces remain physical Sardaukar; no card grants a new force or cancels their separate support benefit.', @@ -4025,7 +4026,7 @@ export const RULE_TOPICS: RuleTopic[] = [ ], related: ['ecaz-modules', 'spice-blow', 'alliance-funding'], checklist: [ - { area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, revealed Face Dancer refresh, turn-wide Suboid strength, whole-territory advisor conversion, temporary Emperor Sardaukar and actual Sardaukar Betrayal suppression, bounded Fremen Secret Ally free-three revival and native empty-worm Cunning, five source-aware CHOAM Cunning effects, Moritani Cunning placement and Betrayal token return, Ecaz Betrayal allied-force return, and Richese Secret Ally reserve shipping priced as one. Borrowed Voice, other Betrayal reactions, Emperor purchase effects, Tleilaxu revival, Fremen worm protection and combined Cunning interactions remain unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/engine.ts'] }, + { area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, revealed Face Dancer refresh, turn-wide Suboid strength, whole-territory advisor conversion and native Voice Betrayal suppression, temporary Emperor Sardaukar and actual Sardaukar Betrayal suppression, bounded Fremen Secret Ally free-three revival and native empty-worm Cunning, five source-aware CHOAM Cunning effects, Moritani Cunning placement and Betrayal token return, Ecaz Betrayal allied-force return, and Richese Secret Ally reserve shipping priced as one. Borrowed Voice, other Betrayal reactions, Emperor purchase effects, Tleilaxu revival, Fremen worm protection and combined Cunning interactions remain unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/engine.ts'] }, { area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation, advisor selection, temporary Sardaukar and CHOAM effects with native Karama responses, Moritani Betrayal token selection, Ecaz Betrayal shared-territory choice, Richese shipment pricing and Fremen Cunning offer/remote ride. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/game-table.tsx'] }, { area: 'AI', status: 'Partial', detail: 'All profiles handle integrated effects through private offers, including Moritani Betrayal public-token selection, Ecaz Betrayal public-territory selection and legal Fremen Cunning offer/ride decisions. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-richese-options.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts'] }, { area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and integrated effect families retain explicit unresolved boundaries, including Ecaz Duke/inquiry, a second Cunning offer during an interrupted Fremen control, Great Maker and combined modules, fresh advisors, storm and borrowed Voice.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md'] }, diff --git a/tests/fixture-nexus-bg-betrayal.ts b/tests/fixture-nexus-bg-betrayal.ts new file mode 100644 index 00000000..883b6d01 --- /dev/null +++ b/tests/fixture-nexus-bg-betrayal.ts @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import { applyAction, type Game } from '../game/engine'; +import { nexusTraitorFixture, nexusTraitorInventory } from './fixture-nexus-traitors'; + +export function nexusBgBetrayalFixture( + holder: 'r' | null = 'r', advanced = false, opponentFaction: 'guild' | 'atreides' | 'harkonnen' = 'guild', +): Game { + let g = nexusTraitorFixture({ ownerFaction: 'beneGesserit', opponentFaction, phase: 6, advanced }); + const cards = g.nexusCards!.cards!; + if (opponentFaction === 'harkonnen') { + assert.equal(cards.hands.p, 'harkonnen'); + assert.equal(cards.hands.q, null); + cards.hands.q = cards.hands.p; + cards.hands.p = null; + } + if (holder) { + const index = cards.deck.indexOf('beneGesserit'); + assert.ok(index >= 0); + if (cards.hands[holder]) cards.deck.push(cards.hands[holder]!); + cards.hands[holder] = cards.deck.splice(index, 1)[0]; + } + for (const player of g.players) { + player.forces = player.id === 'r' ? {} : { 'pasty_mesa:5': 4 }; + player.reserves = player.id === 'r' ? 20 : 16; + player.tanks = 0; + if (player.advisors) player.advisors = {}; + if (player.elites) { + player.elites.forces = {}; + player.elites.reserves = 3; + player.elites.tanks = 0; + } + } + g = applyAction(g, 'p', { + type: 'chooseBattle', territory: 'pasty_mesa', target: 'q', + }); + assert.equal(g.battle?.preparation?.kind, 'voice'); + nexusTraitorInventory(g); + return g; +} diff --git a/tests/nexus-bg-betrayal-controls.test.tsx b/tests/nexus-bg-betrayal-controls.test.tsx new file mode 100644 index 00000000..e79fbf2e --- /dev/null +++ b/tests/nexus-bg-betrayal-controls.test.tsx @@ -0,0 +1,32 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { registerHooks } from 'node:module'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { applyAction, viewGame, type GameView } from '../game/engine'; +import { nexusBgBetrayalFixture } from './fixture-nexus-bg-betrayal'; + +const aliases = registerHooks({ + resolve(specifier, context, next) { + return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier, context); + }, +}); +// NexusCards imports next/image; the test-only alias must exist before module loading. +const { NexusCards } = await import('../components/nexus-cards'); + +function markup(view: GameView, busy = false) { + return renderToStaticMarkup(createElement(NexusCards, { game: view, act() {}, busy })); +} + +void test('private BG holder alone sees an enabled Voice prevention control', () => { + const initial = nexusBgBetrayalFixture(); + const g = applyAction(initial, 'p', { type: 'voice', kind: 'poison', must: false }); + const holder = markup(viewGame(g, 'r')); + assert.match(holder, /Bene Gesserit Betrayal · Voice/); + assert.match(holder, /Prevent Voice with Betrayal/); + assert.doesNotMatch(markup(viewGame(g, 'p')), /Prevent Voice with Betrayal/); + assert.doesNotMatch(markup(viewGame(g, 'q')), /Prevent Voice with Betrayal/); + assert.match(markup(viewGame(g, 'r'), true), /disabled=""[^>]*>Prevent Voice with Betrayal/); +}); + +aliases.deregister(); diff --git a/tests/nexus-bg-betrayal-recovery.test.ts b/tests/nexus-bg-betrayal-recovery.test.ts new file mode 100644 index 00000000..7e6d00e1 --- /dev/null +++ b/tests/nexus-bg-betrayal-recovery.test.ts @@ -0,0 +1,61 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { unitStore } from './fixture-nexus-room-store'; +import { nexusBgBetrayalFixture } from './fixture-nexus-bg-betrayal'; +import { nexusTraitorInventory } from './fixture-nexus-traitors'; +import { applyAction, viewGame } from '../game/engine'; +import type { RoomsClock } from '../db/rooms'; + +const clock: RoomsClock = { now: () => 10000, sleep: async () => {} }; + +void test('SQLite restart preserves private Voice response and exactly one spent BG Nexus card', async (t) => { + const store = unitStore(); + t.after(() => store.sqlite.close()); + const made = await store.rooms.createRoom('BG Betrayal SQL', 'beneGesserit', false, []); + const code = made.view.code; + const tokens = [made.token]; + for (const faction of ['guild', 'fremen'] as const) + tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!); + for (const [index, token] of tokens.entries()) { + const auth = await store.rooms.authenticate(code, token); + store.sqlite.prepare('UPDATE seats SET player_id = ? WHERE room_code = ? AND player_id = ?') + .run(['p', 'q', 'r'][index], code, auth.playerId); + } + const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token))); + const initial = nexusBgBetrayalFixture(); + initial.code = code; + initial.version = (await store.rooms.readRoom(code)).version; + const declared = applyAction(initial, 'p', { + type: 'voice', kind: 'poison', must: false, + }); + store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?') + .run(JSON.stringify(declared), declared.version, code); + store.writes.length = 0; + const action = { type: 'nexusBgBetrayal', event: declared.battle!.event }; + for (const [index, auth] of auths.entries()) { + const seat = await store.restart().readSeatView(code, auth); + assert.deepEqual(seat, viewGame(declared, ['p', 'q', 'r'][index])); + assert.equal(seat.nexusBgBetrayal !== null, index === 2); + assert.equal('nexusBgBetrayalHistory' in seat, false); + } + await assert.rejects(store.rooms.act(code, auths[1], declared.version, action, clock)); + assert.equal(store.writes.length, 0); + await store.rooms.act(code, auths[2], declared.version, action, clock); + const committed = await store.restart().readRoom(code); + assert.equal(committed.version, declared.version + 1); + assert.equal(committed.battle!.voice, undefined); + assert.equal(committed.nexusBgBetrayalHistory?.length, 1); + assert.equal(committed.nexusCards!.cards!.hands.r, null); + assert.equal(committed.nexusCards!.cards!.discard.filter(card => card === 'beneGesserit').length, 1); + nexusTraitorInventory(committed); + const row = store.sqlite.prepare('SELECT state,version FROM rooms WHERE code = ?').get(code); + await assert.rejects(store.restart().act(code, auths[2], declared.version, action, clock)); + await store.restart().continueRoomAutomatic(code, clock); + assert.deepEqual(store.sqlite.prepare('SELECT state,version FROM rooms WHERE code = ?').get(code), row); + for (const [index, auth] of auths.entries()) { + const seat = await store.restart().readSeatView(code, auth); + assert.deepEqual(seat, viewGame(committed, ['p', 'q', 'r'][index])); + assert.equal(seat.nexusBgBetrayal, null); + assert.equal('nexusBgBetrayalHistory' in seat, false); + } +}); diff --git a/tests/nexus-bg-betrayal.test.ts b/tests/nexus-bg-betrayal.test.ts new file mode 100644 index 00000000..56d1dcd5 --- /dev/null +++ b/tests/nexus-bg-betrayal.test.ts @@ -0,0 +1,133 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { nexusTraitorInventory } from './fixture-nexus-traitors'; +import { nexusBgBetrayalFixture } from './fixture-nexus-bg-betrayal'; + +const reload = (g: Game): Game => JSON.parse(JSON.stringify(g)); +const declare = (g: Game) => applyAction(g, 'p', { type: 'voice', kind: 'poison', must: false }); +function rejected(g: Game, id: string, action: Action) { + const before = JSON.stringify(g); + assert.throws(() => applyAction(g, id, action)); + assert.equal(JSON.stringify(g), before); +} + +void test('every seat receives a Voice response with Nexus enabled, even without a hidden holder or Karama', () => { + const g = declare(nexusBgBetrayalFixture()); + assert.equal(g.response?.kind, 'voice'); + assert.equal(g.response.owner, 'p'); + assert.equal(viewGame(g, 'r').nexusBgBetrayal?.event, g.battle!.event); + for (const id of ['p', 'q']) { + assert.equal(viewGame(g, id).nexusBgBetrayal, null); + assert.equal('nexusBgBetrayalHistory' in viewGame(g, id), false); + } + let absent = declare(nexusBgBetrayalFixture(null)); + for (const id of ['p', 'q']) absent = applyAction(absent, id, { type: 'passResponse' }); + assert.equal(absent.response?.kind, 'voice'); + absent = applyAction(absent, 'r', { type: 'passResponse' }); + assert.equal(absent.response, null); + const passed = applyAction(g, 'r', { type: 'passResponse' }); + rejected(passed, 'r', { type: 'nexusBgBetrayal', event: passed.battle!.event }); +}); + +void test('Advanced Voice cancellation retains physical Fremen elite custody and subsequent Prescience timing', () => { + const g = declare(nexusBgBetrayalFixture('r', true, 'atreides')); + assert.equal(g.response?.kind, 'voice'); + const before = g.players[2].elites; + const done = applyAction(g, 'r', { type: 'nexusBgBetrayal', event: g.battle!.event }); + assert.equal(done.battle!.voice, undefined); + assert.equal(done.battle!.preparation?.kind, 'prescience'); + assert.equal(done.battle!.preparation?.owner, 'q'); + assert.deepEqual(done.players[2].elites, before); + assert.equal(done.nexusBgBetrayalHistory?.length, 1); + nexusTraitorInventory(done); +}); + +void test('a pending Harkonnen Nexus return suspends Voice Betrayal and restores it on completion', () => { + let g = declare(nexusBgBetrayalFixture('r', false, 'harkonnen')); + const offer = viewGame(g, 'q').nexusTraitors!.offer!; + assert.equal(offer.mode, 'cunning'); + g = applyAction(g, 'q', { type: 'nexusTraitorDraw', event: offer.event, mode: offer.mode }); + assert.equal(viewGame(g, 'r').nexusBgBetrayal, null); + rejected(g, 'r', { type: 'nexusBgBetrayal', event: g.battle!.event }); + 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.equal(viewGame(g, 'r').nexusBgBetrayal?.event, g.battle!.event); + const canceled = applyAction(g, 'r', { type: 'nexusBgBetrayal', event: g.battle!.event }); + assert.equal(canceled.battle!.voice, undefined); + nexusTraitorInventory(canceled); +}); + +void test('one physical BG Nexus card cancels only the declared Voice, preserving plans and replay safety', () => { + const initial = declare(nexusBgBetrayalFixture()); + const action = { type: 'nexusBgBetrayal', event: initial.battle!.event }; + rejected(initial, 'p', action); + rejected(initial, 'q', action); + rejected(initial, 'r', { ...action, event: 'old' }); + rejected(initial, 'r', { ...action, injected: true }); + const voice = initial.battle!.voice; + assert.ok(voice); + const after = applyAction(reload(initial), 'r', action); + assert.equal(after.response, null); + assert.equal(after.battle!.voice, undefined); + assert.equal(after.battle!.plans.q, undefined); + assert.equal(after.nexusCards!.cards!.hands.r, null); + assert.equal(after.nexusCards!.cards!.discard.filter(card => card === 'beneGesserit').length, 1); + assert.equal(after.nexusBgBetrayalHistory?.length, 1); + rejected(after, 'r', action); + assert.deepEqual(normalizeAutomaticGame(reload(after)), reload(after)); + nexusTraitorInventory(after); +}); + +void test('prevented Voice allows the otherwise forbidden physical poison plan', () => { + const initial = nexusBgBetrayalFixture(); + const poison = initial.deck.find(card => card.kind === 'poison')!; + initial.deck = initial.deck.filter(card => card.id !== poison.id); + initial.players[1].hand.push(poison); + const announced = declare(initial); + let allowed = announced; + for (const id of ['p', 'q', 'r']) + allowed = applyAction(allowed, id, { type: 'passResponse' }); + const plan = { type: 'battlePlan', leader: 'guild-0', weapon: poison.id, dial: 0 }; + rejected(allowed, 'q', plan); + const betrayed = applyAction(announced, 'r', { type: 'nexusBgBetrayal', event: announced.battle!.event }); + assert.equal(applyAction(betrayed, 'q', plan).battle!.plans.q.weapon, poison.id); + assert.deepEqual(betrayed.players[1].hand, announced.players[1].hand); +}); + +void test('active Truthtrance suspends the private Voice reaction without consuming its card', () => { + const initial = declare(nexusBgBetrayalFixture()); + const card = initial.deck.find(candidate => candidate.effect === 'truthtrance')!; + initial.deck = initial.deck.filter(candidate => candidate.id !== card.id); + initial.players[1].hand.push(card); + let waiting = applyAction(initial, 'q', { type: 'card', card: card.id }); + assert.equal(waiting.truthtrance?.stage, 'priority'); + assert.equal(viewGame(waiting, 'r').nexusBgBetrayal, null); + rejected(waiting, 'r', { type: 'nexusBgBetrayal', event: waiting.battle!.event }); + while (waiting.truthtrance?.stage === 'priority') + waiting = applyAction(waiting, waiting.players.find(p => !waiting.truthtrance!.passed.includes(p.id))!.id, + { type: 'truthPass' }); + waiting = applyAction(waiting, 'q', { type: 'truthAsk', question: { + kind: 'fact', target: 'p', fact: { kind: 'hand', name: 'Shield' }, + } }); + rejected(waiting, 'r', { type: 'nexusBgBetrayal', event: waiting.battle!.event }); + waiting = applyAction(waiting, 'p', { type: 'truthAnswer', answer: 'no' }); + assert.equal(viewGame(waiting, 'r').nexusBgBetrayal?.event, waiting.battle!.event); +}); + +void test('all bot profiles spend their private card or pass the uniform Voice response', () => { + for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) { + const g = declare(nexusBgBetrayalFixture()); + g.players[2].bot = difficulty; + const action = botActions(viewGame(g, 'r'))[0]; + assert.equal(action?.type, 'nexusBgBetrayal'); + assert.equal(applyAction(g, 'r', action).battle!.voice, undefined); + const absent = declare(nexusBgBetrayalFixture(null)); + absent.players[2].bot = difficulty; + assert.equal(botActions(viewGame(absent, 'r'))[0]?.type, 'passResponse'); + } +});