diff --git a/components/game-table.tsx b/components/game-table.tsx index 527b917f..1a67d2ed 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -2155,6 +2155,14 @@ export function GameTable({ using this power. Another faction may play Karama to cancel this use.

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

+ Every seat may respond before the Emperor’s Sardaukar strength is locked. + A private Emperor Nexus Betrayal may suppress it without moving starred forces. +

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

@@ -2205,8 +2213,10 @@ export function GameTable({ : 'Cancel with Karama'} ))} - {g.responseControls?.cancelCards.length && - !g.responseControls.hasPassed ? ( + {(g.responseControls?.cancelCards.length || + (g.response.kind === 'eliteStrength' && g.nexusCards && + g.players.some((player) => player.id === g.response?.owner && player.faction === 'emperor'))) && + !g.responseControls?.hasPassed ? ( actionButton('Allow this power', { type: 'passResponse' }) ) : (

diff --git a/components/nexus-cards.tsx b/components/nexus-cards.tsx index ed01eec4..ba5ce751 100644 --- a/components/nexus-cards.tsx +++ b/components/nexus-cards.tsx @@ -145,6 +145,19 @@ export function NexusCards({ game, act, busy }: { game: GameView; act: (action: )} )} + {game.nexusEmperorBetrayal && offer.card === 'emperor' && ( +

+

Emperor Betrayal · Sardaukar

+

+ Spend your Nexus card during this battle response to prevent the Emperor’s + Sardaukar strength advantage. Its starred forces remain physical Sardaukar. +

+ +
+ )}
diff --git a/docs/NEXUS_EMPEROR_RULES.md b/docs/NEXUS_EMPEROR_RULES.md index f42c70ea..95c55627 100644 --- a/docs/NEXUS_EMPEROR_RULES.md +++ b/docs/NEXUS_EMPEROR_RULES.md @@ -62,12 +62,33 @@ E1 pays Tleilaxu for factions using free revival or Ghola; E2's La La La prevent Normal Emperor-funded auction help goes to the ally and is paid back to Emperor, according to November's FAQ. Therefore forcing its contribution cannot automatically be implemented as transferring its spice to the bank. Bind the actual allied purchase and its original price/contributions. [November FAQ, p.2](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=2) +### Implemented battle alternative (bounded) + +In an Advanced battle against a non-Fremen faction with actual Emperor Sardaukar, +the existing `eliteStrength` response now remains open for every seat when Nexus +cards are enabled, even if no player holds a canceling card. This prevents the +response's presence or duration from identifying a hidden Emperor Nexus holder. +An unallied non-Emperor holder may spend the card during that response, before +plans are submitted, to suppress the Emperor's Sardaukar strength for this +battle. The normal combat response quote rechecks legal plans and preserves +physical starred counters and their casualty custody. Seats without a card +explicitly pass; bots do so too. Truthtrance temporarily hides and blocks the +card reaction until its priority and question resolve. The spent card, battle +receipt, suppression and seat-private offer survive JSON and room restart; +replay cannot spend it twice. Karama remains an independent alternative in +the same window. + +This does not implement the forced-purchase Betrayal alternative or the +Secret Ally purchase. Emperor Cunning still has its own earlier declaration +and response; one unique Emperor Nexus card cannot be both Cunning and +Betrayal at once. The wider Nexus module remains gated. + The following material cases remain unresolved; no new question was sent: - **Short groups:** may Cunning operate with fewer than five eligible ordinary counters, or Secret Ally revive fewer than three when Tanks/typed limits cannot supply three? Neither panel says up to. Do not disguise the question as a silently truncated count. - **Forced payment:** who chooses Emperor's compulsory contribution, what is the minimum when its ally already holds the full price, and what occurs if Emperor cannot cover the requested amount? The panel's necessary-amount wording does not settle the complete digital transaction. - **Special purchases and revival suppression/income:** resolve the concrete boundaries above before connecting broad existing payment/free-revival adapters. -The existing private reaction-policy question already covers Betrayal timing; do not duplicate it. A native holder and a Betrayal holder cannot possess the same unique Emperor card simultaneously, so do not manufacture Betrayal-versus-Cunning tests with duplicate physical copies. +The existing private reaction-policy question still covers other hidden Betrayal timing. This battle path uses a uniform native response rather than an identity-dependent private pause. A native holder and a Betrayal holder cannot possess the same unique Emperor card simultaneously, so do not manufacture Betrayal-versus-Cunning tests with duplicate physical copies. Suggested first package: one-battle Cunning with five available normal counters, genuine no-Sardaukar eligibility, native cancellation, physical casualty mapping, current Salusa support and prior commitments. Verify ordinary victories, traitors, explosions, Fremen, later battles, JSON response recovery, corruption, all-seat privacy and all bot profiles before claiming that bounded package. No runtime or tests were changed by this audit. diff --git a/game/bots.ts b/game/bots.ts index 613936e2..0e0868a5 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -1420,6 +1420,8 @@ function policyActions(g: GameView): Action[] { { type: 'card', mode: 'special', card: specialCard.id, amount, elite }, ]; } + if (g.nexusEmperorBetrayal) + return [{ type: 'nexusEmperorBetrayal', event: g.nexusEmperorBetrayal.event }]; if (g.response) { const benefitEnemy = g.response.owner !== me.id && g.response.owner !== me.ally; @@ -1471,6 +1473,10 @@ 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) + return [{ type: 'passResponse' }]; return !g.responseControls?.cancelCards.length || g.responseControls.hasPassed ? [] diff --git a/game/engine.ts b/game/engine.ts index 17b64e81..a5239b73 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1466,6 +1466,8 @@ export type Game = { }[]; /** A spent Ecaz card binds one exact allied force group until Karama settles. */ 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 }[]; nexusFaceDancerHistory?: NexusFaceDancerReceipt[]; nexusSuboidHistory?: NexusSuboidReceipt[]; nexusSuboidLast?: { event: string; owner: string; turn: number }; @@ -1755,6 +1757,60 @@ function currentNexusChoamTrade(g: Game, owner: string) { return nexusRule(() => quoteNexusChoamTrade(g, owner, g.phase === 7 && grummanCollectionAutomatic(g))); } +function emperorBetrayalWindow(g: Game) { + const response = g.response, battle = g.battle; + 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) && + !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 || + 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 }; +} +function nexusEmperorBetrayalIntegrity(g: Game) { + const history = g.nexusEmperorBetrayalHistory; + if (history === undefined) return; + requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0, + 'Emperor 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 === 'emperor') && + !events.has(record.event), + 'Emperor Betrayal has a malformed or repeated battle receipt.'); + events.add(record.event); + } + const last = history.at(-1)!; + if (last.turn === g.turn) requireRule(g.nexusCards.cards.discard.includes('emperor'), + 'Emperor Betrayal has lost its spent physical card.'); + if (g.battle?.event === last.event) requireRule(g.battle.territory === last.territory && + g.battle.eliteBlocked?.includes(last.target), + 'The current battle has lost its Emperor Betrayal suppression.'); +} +function playNexusEmperorBetrayal(g: Game, p: Player, action: Action) { + const offer = nexusEmperorBetrayalOffer(g, p.id); + requireRule(offer && action.event === offer.event && + Object.keys(action).sort().join(',') === 'event,type', + 'Choose your current Emperor Sardaukar battle response.'); + const response = g.response!; + const record = { event: offer.event, turn: g.turn, territory: g.battle!.territory, + owner: p.id, target: response.owner }; + g.nexusCards!.cards = nexusRule(() => + discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); + (g.nexusEmperorBetrayalHistory ??= []).push(record); + finishResponse(g, true); + 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 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; @@ -14818,6 +14874,7 @@ function marketGholaIntegrity(g: Game) { saphoAggressorIntegrity(g); nexusCardsIntegrity(g); nexusEmperorSecretIntegrity(g); + nexusEmperorBetrayalIntegrity(g); nexusFremenRevivalIntegrity(g); nexusFremenCunningIntegrity(g); nexusEcazBetrayalIntegrity(g); @@ -21317,7 +21374,8 @@ function settleAutomaticContinuations(g: Game) { g.players.some( (p) => !response.passed.includes(p.id) && - responseCancelCards(g, p, response).length > 0, + (emperorBetrayalWindow(g) || + responseCancelCards(g, p, response).length > 0), ) ) return; @@ -21477,6 +21535,7 @@ function applyActionInner( if (t === 'discovery') { playDiscovery(g, p, action); return g; } 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 === '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; } @@ -25393,6 +25452,7 @@ export function viewGame(state: Game, id: string) { nexusCards: projectedNexusCards(g, id), nexusChoamTrade: currentNexusChoamTrade(g, id), nexusEmperorSecretAlly: nexusEmperorSecretAllyOffer(g,id), + nexusEmperorBetrayal: nexusEmperorBetrayalOffer(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 5aa67823..35b0c3b3 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4007,7 +4007,8 @@ export const RULE_TOPICS: RuleTopic[] = [ '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.', '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; other Emperor modes remain unfinished.', + '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.', 'With Fremen absent, an unallied holder may spend Fremen Secret Ally during Revival to return exactly three eligible forces from Tanks to reserves at no spice cost. Unlike Emperor Secret Ally, these three consume the ordinary force and free-force allowances, including the Advanced one-elite cap. This first path requires base factions and Nexus alone, before any normal force returns, without combined revival effects. Partial returns, prior returns, higher free rates and separate sandworm protection remain unavailable.', 'Native Fremen Cunning has a bounded Nexus-only path: when a natural, accepted additional, or Advanced special-Karama summoned worm appears in a territory initially containing no forces, Fremen may spend their own Nexus card before the worm resolves. The special summon retains its original Spice Blow parent. After the applicable Nexus, choose some forces from one occupied desert territory and ride to a legal Arrakis destination without using normal movement. The choice appears even without the secret card, so declining does not disclose custody. Karama provisionally prevents only the additional remote ride; the original worm still resolves and the card remains spent. An interrupted existing Cunning control is restored, and a selected ride waits for the summoned Nexus without granting a second Cunning offer; Great Maker and combined optional modules await integration.', 'CHOAM Secret Ally has a working Collection trade: when CHOAM is absent, an unallied holder may discard one Worthless card and the Nexus card for two bank spice. Its after-victory inspection remains unfinished.', @@ -4024,7 +4025,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, 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, 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, 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-sardaukar.ts b/tests/fixture-nexus-sardaukar.ts index a0795c7b..433819f5 100644 --- a/tests/fixture-nexus-sardaukar.ts +++ b/tests/fixture-nexus-sardaukar.ts @@ -13,6 +13,7 @@ export function nexusSardaukarFixture( opponentFaction?: 'guild' | 'atreides' | 'fremen'; normal?: number; starred?: number; + emperorCardHolder?: string; } = {}, ): Game { let g = nexusTraitorFixture({ @@ -35,6 +36,13 @@ export function nexusSardaukarFixture( assert.ok(index >= 0); cards.deck[index] = cards.hands[owner.id]!; cards.hands[owner.id] = 'emperor'; + if (options.emperorCardHolder) { + const holder = g.players.find((p) => p.id === options.emperorCardHolder); + assert.ok(holder && holder.id !== owner.id); + if (cards.hands[holder.id]) cards.deck.push(cards.hands[holder.id]!); + cards.hands[holder.id] = cards.hands[owner.id]; + cards.hands[owner.id] = null; + } for (const p of g.players) { p.forces = {}; p.reserves = 20; diff --git a/tests/nexus-emperor-betrayal-controls.test.tsx b/tests/nexus-emperor-betrayal-controls.test.tsx new file mode 100644 index 00000000..e53cb5a4 --- /dev/null +++ b/tests/nexus-emperor-betrayal-controls.test.tsx @@ -0,0 +1,31 @@ +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 { viewGame, type GameView } from '../game/engine'; +import { nexusSardaukarFixture } from './fixture-nexus-sardaukar'; + +const aliases = registerHooks({ + resolve(specifier, context, next) { + return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier, context); + }, +}); +// NexusCards imports next/image; register the test-only Vinext alias before loading it. +const { NexusCards } = await import('../components/nexus-cards'); + +function markup(view: GameView, busy = false) { + return renderToStaticMarkup(createElement(NexusCards, { game: view, act() {}, busy })); +} + +void test('only the private Emperor Betrayal holder can see its active battle control', () => { + const g = nexusSardaukarFixture({ starred: 2, normal: 3, emperorCardHolder: 'r' }); + const holder = markup(viewGame(g, 'r')); + assert.match(holder, /Emperor Betrayal · Sardaukar/); + assert.match(holder, /Suppress Sardaukar with Betrayal/); + assert.doesNotMatch(markup(viewGame(g, 'p')), /Suppress Sardaukar with Betrayal/); + assert.doesNotMatch(markup(viewGame(g, 'q')), /Suppress Sardaukar with Betrayal/); + assert.match(markup(viewGame(g, 'r'), true), /disabled=""[^>]*>Suppress Sardaukar with Betrayal/); +}); + +aliases.deregister(); diff --git a/tests/nexus-emperor-betrayal-recovery.test.ts b/tests/nexus-emperor-betrayal-recovery.test.ts new file mode 100644 index 00000000..9ba1f09d --- /dev/null +++ b/tests/nexus-emperor-betrayal-recovery.test.ts @@ -0,0 +1,57 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { unitStore } from './fixture-nexus-room-store'; +import { nexusSardaukarFixture, nexusSardaukarInventory } from './fixture-nexus-sardaukar'; +import { viewGame } from '../game/engine'; +import type { RoomsClock } from '../db/rooms'; + +const clock: RoomsClock = { now: () => 10000, sleep: async () => {} }; + +void test('SQLite restart, seat privacy and lost acknowledgment preserve exactly one Emperor Betrayal', async (t) => { + const store = unitStore(); + t.after(() => store.sqlite.close()); + const made = await store.rooms.createRoom('Emperor Betrayal SQL', 'emperor', true, []); + 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 g = nexusSardaukarFixture({ starred: 2, normal: 3, emperorCardHolder: 'r' }); + g.code = code; + g.version = (await store.rooms.readRoom(code)).version; + store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?') + .run(JSON.stringify(g), g.version, code); + store.writes.length = 0; + const action = { type: 'nexusEmperorBetrayal', event: g.battle!.event }; + for (const [index, auth] of auths.entries()) { + const view = await store.restart().readSeatView(code, auth); + assert.deepEqual(view, viewGame(g, ['p', 'q', 'r'][index])); + assert.equal(view.nexusEmperorBetrayal !== null, index === 2); + assert.equal('nexusEmperorBetrayalHistory' in view, false); + } + await assert.rejects(store.rooms.act(code, auths[1], g.version, action, clock)); + assert.equal(store.writes.length, 0); + await store.rooms.act(code, auths[2], g.version, action, clock); + const committed = await store.restart().readRoom(code); + assert.equal(committed.version, g.version + 1); + assert.equal(committed.nexusEmperorBetrayalHistory?.length, 1); + assert.ok(committed.battle!.eliteBlocked?.includes('p')); + assert.equal(committed.nexusCards!.cards!.hands.r, null); + assert.equal(committed.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1); + nexusSardaukarInventory(committed); + const row = store.sqlite.prepare('SELECT state,version FROM rooms WHERE code = ?').get(code); + await assert.rejects(store.restart().act(code, auths[2], g.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 view = await store.restart().readSeatView(code, auth); + assert.deepEqual(view, viewGame(committed, ['p', 'q', 'r'][index])); + assert.equal(view.nexusEmperorBetrayal, null); + assert.equal('nexusEmperorBetrayalHistory' in view, false); + } +}); diff --git a/tests/nexus-emperor-betrayal.test.ts b/tests/nexus-emperor-betrayal.test.ts new file mode 100644 index 00000000..2a22cbb8 --- /dev/null +++ b/tests/nexus-emperor-betrayal.test.ts @@ -0,0 +1,116 @@ +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 { nexusSardaukarFixture, nexusSardaukarInventory } from './fixture-nexus-sardaukar'; +import { maxCombatDial } from '../game/combat'; +import { nexusReload } from './fixture-nexus-cards'; + +function battle(holder: 'r' | null = 'r') { + const g = nexusSardaukarFixture({ starred: 2, normal: 3, ...(holder ? { emperorCardHolder: holder } : {}) }); + nexusSardaukarInventory(g); + assert.equal(g.response?.kind, 'eliteStrength'); + assert.equal(g.response.owner, 'p'); + return g; +} + +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('Emperor battle response is a universal opportunity without identifying the hidden Nexus holder', () => { + const g = battle(); + assert.equal(viewGame(g, 'p').nexusEmperorBetrayal, null); + assert.equal(viewGame(g, 'q').nexusEmperorBetrayal, null); + assert.equal(viewGame(g, 'r').nexusEmperorBetrayal?.event, g.battle!.event); + for (const id of ['p', 'q', 'r']) { + const publicView = viewGame(g, id); + assert.equal(publicView.response?.kind, 'eliteStrength'); + assert.equal('nexusEmperorBetrayalHistory' in publicView, false); + } + let noHolder = battle(null); + for (const id of ['p', 'q']) noHolder = applyAction(noHolder, id, { type: 'passResponse' }); + assert.equal(noHolder.response?.kind, 'eliteStrength'); + noHolder = applyAction(noHolder, 'r', { type: 'passResponse' }); + assert.notEqual(noHolder.response?.kind, 'eliteStrength'); + const passed = applyAction(battle(), 'r', { type: 'passResponse' }); + assert.equal(passed.response?.kind, 'eliteStrength'); + assert.equal(viewGame(passed, 'r').nexusEmperorBetrayal, null); + rejected(passed, 'r', { type: 'nexusEmperorBetrayal', event: passed.battle!.event }); +}); + +void test('Truthtrance priority suspends the battle reaction until the question resolves', () => { + const initial = battle(); + 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 g = applyAction(initial, 'q', { type: 'card', card: card.id }); + assert.equal(g.truthtrance?.stage, 'priority'); + assert.equal(viewGame(g, 'r').nexusEmperorBetrayal, null); + rejected(g, 'r', { type: 'nexusEmperorBetrayal', event: g.battle!.event }); + while (g.truthtrance?.stage === 'priority') + g = applyAction(g, g.players.find((player) => !g.truthtrance!.passed.includes(player.id))!.id, + { type: 'truthPass' }); + g = applyAction(g, 'q', { type: 'truthAsk', question: { + kind: 'battlePlan', target: 'p', claim: { kind: 'dial', compare: 'gte', value: 6 }, + } }); + assert.equal(viewGame(g, 'r').nexusEmperorBetrayal, null); + rejected(g, 'r', { type: 'nexusEmperorBetrayal', event: g.battle!.event }); + g = applyAction(g, 'p', { type: 'truthAnswer', answer: 'no' }); + assert.equal(g.truthtrance, null); + assert.equal(viewGame(g, 'r').nexusEmperorBetrayal?.event, g.battle!.event); + assert.ok(applyAction(g, 'r', { type: 'nexusEmperorBetrayal', event: g.battle!.event }) + .battle!.eliteBlocked?.includes('p')); +}); + +void test('one spent Emperor Betrayal suppresses actual Sardaukar strength but retains physical counters after restore', () => { + const original = battle(); + const action = { type: 'nexusEmperorBetrayal', event: original.battle!.event }; + rejected(original, 'q', action); + rejected(original, 'p', action); + rejected(original, 'r', { ...action, event: 'stale' }); + rejected(original, 'r', { ...action, invented: true }); + const before = original.players[0].elites; + const g = applyAction(nexusReload(original), 'r', action); + assert.equal(g.nexusCards!.cards!.hands.r, null); + assert.ok(g.nexusCards!.cards!.discard.includes('emperor')); + assert.ok(g.battle!.eliteBlocked?.includes('p')); + assert.deepEqual(g.players[0].elites, before); + assert.equal(g.nexusEmperorBetrayalHistory?.length, 1); + nexusSardaukarInventory(g); + rejected(g, 'r', action); + assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), nexusReload(g)); + const after = viewGame(g, 'q'); + assert.equal(after.nexusEmperorBetrayal, null); + assert.equal('nexusEmperorBetrayalHistory' in after, false); +}); + +void test('Betrayal changes supported dial legality, not actual Sardaukar custody', () => { + const initial = battle(); + assert.equal(maxCombatDial(viewGame(initial, 'p').battle!.ownForces!), 7); + let g = applyAction(initial, 'r', { + type: 'nexusEmperorBetrayal', event: initial.battle!.event, + }); + assert.equal(maxCombatDial(viewGame(g, 'p').battle!.ownForces!), 5); + while (g.battle?.preparation) + g = applyAction(g, g.battle.preparation.owner, { type: 'declineBattlePower' }); + rejected(g, 'p', { type: 'battlePlan', leader: 'emperor-0', dial: 6, support: 5 }); + const legal = applyAction(g, 'p', { type: 'battlePlan', leader: 'emperor-0', dial: 5, support: 5 }); + assert.equal(legal.battle!.plans.p.dial, 5); + assert.equal(legal.players[0].elites!.forces['pasty_mesa:5'], 2); +}); + +void test('all bot tiers either play their private Betrayal or legally pass the shared response', () => { + for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) { + const g = battle(); + g.players[2].bot = difficulty; + const action = botActions(viewGame(g, 'r'))[0]; + assert.equal(action?.type, 'nexusEmperorBetrayal'); + const absent = battle(null); + absent.players[2].bot = difficulty; + const pass = botActions(viewGame(absent, 'r'))[0]; + assert.equal(pass?.type, 'passResponse'); + } +});