From 178251ad35228009ed4bbfa7361d6283483372f8 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:08:55 +1100 Subject: [PATCH] Restore native Ecaz simulation continuation and current rule fixtures Match original Ecaz sample snapshots to their native Occupy and Advanced assassination state. Preserve older absent-power samples and resume the original scenario instead of rejecting fresh saved output. Update stale fixture reactions and module expectations while retaining meaningful Banker guidance and positive legal commitments. Correct the unsupported rules-completion claim. Validation: typecheck/lint, 173 affected tests across 16 files, bounded independent source review, and one direct Ecaz continuation completed through 178 accepted actions. Runtime fingerprint 95d009d3957650b08bb24826b70aa5f00a74cdd9992b6a6c222f0665d3e3759c; documentation-only proof update a91ab248109bee74a74373fb544027a2e7cc8f178e26d2c9dad475a9618eb64d. Full rules, comprehensive assurance and deployed acceptance remain open. No saved-room or provider changes. --- docs/CURRENT_STATUS.md | 30 ++++--- docs/VERIFICATION_WORKFLOW.md | 8 +- tests/bg-karama-cancel-context.test.ts | 7 +- tests/bot-arrival.test.ts | 11 ++- tests/bribe-controls.test.tsx | 12 ++- tests/classic-nexus-core.test.ts | 70 ++++++++++++++- tests/deferred-movement-arrival-fixture.ts | 10 ++- ...standalone-nexus-e3-skills-runtime.test.ts | 24 ++++- tests/faction-games-cli.test.ts | 87 ++++++++++++++++--- .../fixture-homeworld-classic-nexus-skills.ts | 13 ++- tests/fixture-nexus-advisor-fresh.ts | 13 ++- tests/fixture-nexus-borrowed-voice.ts | 13 ++- tests/fixture-nexus-fremen-protection.ts | 13 ++- ...world-classic-nexus-skills-runtime.test.ts | 6 ++ tests/homeworld-payment-income-engine.test.ts | 4 +- tests/ix-skills-movement.test.ts | 8 +- tests/nexus-advisor-storm-runtime.test.ts | 8 ++ tests/nexus-emperor-purchase-engine.test.ts | 12 ++- tests/nexus-guild-betrayal-controls.test.tsx | 2 - tests/no-field-reinforcements.test.ts | 11 ++- tests/prototype-room.test.ts | 11 +-- tests/sandmaster-movement-controls.test.tsx | 41 +++++++-- tests/spice-banker-controls.test.tsx | 76 ++++++---------- tools/faction-games.ts | 27 ++++-- 24 files changed, 389 insertions(+), 128 deletions(-) diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index a0ca8261..4384fae8 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,17 +29,25 @@ optional skill bands and combinations remain open. Rules implementation continues; comprehensive assurance and administration remain deferred until all rules are implemented. -**Non-AI checklist closure — 18 October.** A full `game/reference.ts` -scan confirms no topic carries a Planned/Missing/Not-started development -stage and no Partial detail names an unimplemented non-AI feature. The only -remaining Planned area is table-discussion AI negotiation, explicitly deferred -until after non-AI completion per the AI plan. All fourteen skill bands have -connected controls; the combined-expansion envelopes (classic core + base33/ -independent36 + Nexus12 + optional14 + Tech3+ + Strongholds2+ + base33 Worlds) -were progressively composed across this session's checkpoints. Remaining -Partial areas describe bounded contracts awaiting full-mode acceptance, not -absent functionality. Rules-first prototype delivery is complete; interaction -refinement, comprehensive review and deferred assurance follow. +**Integration review — 18 October.** The earlier non-AI checklist closure +was unsupported: a topic marked Prototyped/Partial does not prove every +rules-permitted combination works. Independent36 World/Nexus and other named +interaction boundaries remain open. Rules delivery is not complete, and the +deferred comprehensive multiplayer/privacy/custody gate has not been opened. + +The regression repair now admits original Ecaz CLI snapshots with their actual +native Occupy/Advanced assassination state. Resume preserves the saved scenario +and continues play; older snapshots with both powers absent retain that absence. +The focused Ecaz cases pass, including complete expansion-roster continuation. +Independent bounded review found no rule-contract blocker in the other repaired +fixtures. Banker controls retain their rules guidance, and all four legal policies +must actually fund a positive commitment in both original and combined-deck play. +Original city Collection and the selected deck's phase-opening window remain +separate from the battle payment. Frozen16-file **173/173**, types/lint and a direct original Ecaz CLI +resume-to-completion smoke (178 accepted actions) pass. Bounded source review +found no remaining blocker; no worker remains assigned to these files. The prior +recovery902/902 and HTTP55/55 are bounded historical evidence, not complete rules, +multiplayer or deployed acceptance. Comprehensive assurance remains deferred. **Original World Nexus core — 18 October. Prototyped / Partial.** Plain base33 World/Nexus lacked native reaction states; one public capability diff --git a/docs/VERIFICATION_WORKFLOW.md b/docs/VERIFICATION_WORKFLOW.md index 9e1dd92c..aac32f3f 100644 --- a/docs/VERIFICATION_WORKFLOW.md +++ b/docs/VERIFICATION_WORKFLOW.md @@ -172,7 +172,13 @@ fixed four-seat samples. Their seed offsets are 68–77, 78–87 and 88–97. independent three-card Treachery variant instead of Nexus Cards. Basic and Advanced seed offsets are 136–145; default/all keeps its original six games. The runner counts all three physical cards and resume requires the variant -marker and exact roster. This is still a gated development profile. +marker and exact roster. Original `ecaz-treachery` and `ecaz-roster` snapshots +retain their native Ecaz Occupy and, in Advanced, Moritani assassination state +under their own scenario names. Older snapshots with both native powers absent +remain resumable without adding them or inventing ability history; partial native +state and unrelated module overlays are not admitted. Resume continues the saved +turn, phase and custody rather than running setup again. These are still gated +development profiles. `moritani-assassinate` runs only Advanced with Moritani followed by Atreides, Emperor, Fremen, Guild and Bene Gesserit as seats are added. Harkonnen and other optional modules remain outside this explicit development preview. diff --git a/tests/bg-karama-cancel-context.test.ts b/tests/bg-karama-cancel-context.test.ts index ef884d17..715ce53f 100644 --- a/tests/bg-karama-cancel-context.test.ts +++ b/tests/bg-karama-cancel-context.test.ts @@ -260,7 +260,10 @@ void test('paid auction income cancellation survives a real Harkonnen exchange a auction = structuredClone(g.auction), ids = inventory(g); g = beginCancel(g, f); - const stamped = structuredClone(g.pendingKarama); + // The paid auction income response now stamps optional Bureaucrat fields + // whose absent values are JSON-invisible; the Box suspension serializes, so + // compare the pending acknowledgement through the same serialization. + const stamped = JSON.parse(JSON.stringify(g.pendingKarama)); g = action(g, 'h', { type: 'card', mode: 'special', @@ -270,7 +273,7 @@ void test('paid auction income cancellation survives a real Harkonnen exchange a }); assert.equal(g.decision?.kind, 'handExchange'); assert.equal(g.pendingExchange?.response?.kind, 'worthlessKarama'); - assert.deepEqual(g.pendingKarama, stamped); + assert.deepEqual(JSON.parse(JSON.stringify(g.pendingKarama)), stamped); const sampled = structuredClone(g.players.map((p) => p.hand)); g = finishBox(paidBox(g, f)); assert.deepEqual(g.pendingKarama, stamped); diff --git a/tests/bot-arrival.test.ts b/tests/bot-arrival.test.ts index 025009f4..b17f8297 100644 --- a/tests/bot-arrival.test.ts +++ b/tests/bot-arrival.test.ts @@ -1,10 +1,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { botArrivalBlock } from '../game/bot-arrival'; -import { botActions } from '../game/bots'; import { applyAction, viewGame, type Action, type Game } from '../game/engine'; -import { createHomeworldCustody } from '../game/homeworld-custody'; -import { homeworldContext } from '../game/homeworld-game'; +import { botActions } from '../game/bots'; +import { botArrivalBlock } from '../game/bot-arrival'; import { createTerrorState, placeTerror } from '../game/moritani-terror'; import { arrivalMove, @@ -49,8 +47,9 @@ void test('all four profiles avoid unsupported paired Ambassador and Terror entr }); void test('Homeworld-sourced AI shipments avoid unsupported paired entry reactions', () => { - const game = movementArrivalGame(); - game.homeworlds = { custody: createHomeworldCustody(homeworldContext(game)) }; + // Homeworlds now compose with this faction roster through the fresh + // occupation audit, so the custody comes from real setup instead of a graft. + const game = movementArrivalGame('tleilaxu', 'both', true); arrivalPlayer(game, 'tleilaxu').shipped = false; const action: Action = { type: 'ship', diff --git a/tests/bribe-controls.test.tsx b/tests/bribe-controls.test.tsx index 35516199..afb17b17 100644 --- a/tests/bribe-controls.test.tsx +++ b/tests/bribe-controls.test.tsx @@ -109,10 +109,14 @@ void test('Bureaucrat eligibility stays recipient-specific and a saved payment o }); assert.equal(options(settled, 'e').incoming, 3); assert.equal(options(settled, 'p').available, 25); - const unsupported = structuredClone(g); - unsupported.expansions.push('ix'); - assert.throws(() => options(unsupported, 'p')); - reject(unsupported, 'p', { type: 'bribe', target: 'e', amount: 5 }); + // The intentional composition change admits the selected ix deck alongside + // Skills, so the same payment stays available and offers its decision there. + const combined = structuredClone(g); + combined.expansions.push('ix'); + const combinedQuote = options(combined, 'p'); + assert.equal(combinedQuote.blocked, null); + const combinedPaid = applyAction(combined, 'p', bribeAction(combinedQuote, 'e', 5)!); + assert.equal(combinedPaid.decision?.kind, 'bureaucratPayment'); }); void test('bribe controls render recipient choices, private escrow and disabled unselected or busy payment', () => { diff --git a/tests/classic-nexus-core.test.ts b/tests/classic-nexus-core.test.ts index fc56e136..fbdbf5fe 100644 --- a/tests/classic-nexus-core.test.ts +++ b/tests/classic-nexus-core.test.ts @@ -2,11 +2,79 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { viewGame } from '../game/engine'; import { createIndependentHarkonnenCall, createIndependentIxianPurchase, - reactionAction, reactionPlayer } from './fixture-classic-independent-nexus-reactions'; + drawClassicNexusReaction, reactionAction, reactionPlayer } from './fixture-classic-independent-nexus-reactions'; import { advanceClassicIndependentNexus } from './fixture-classic-independent-nexus'; import { classicDiscoveryNexusClean } from './fixture-discovery-classic-nexus'; import { createClassicNexusGuildPayment, createClassicNexusCunning, submitClassicNexusSecondShipment } from './fixture-classic-nexus-core'; +import { TERRITORIES } from '../game/board'; +import { nativeShipmentSources } from '../game/homeworld-options'; + +function smugglerFeeGame(homeworlds: boolean) { + const fixture = drawClassicNexusReaction('harkonnen', 'atreides', { + advanced: true, skills: true, independent: false, homeworlds, + roster: ['atreides', 'guild', 'harkonnen', 'emperor'], + training: { atreides: 'smuggler', guild: 'mentat', harkonnen: 'bureaucrat', emperor: 'sandmaster' }, + }); + let game = fixture.game; + const cards = game.nexusCards!.cards!, index = cards.deck.indexOf('guild'); + assert.ok(index >= 0); + cards.deck.unshift(cards.deck.splice(index, 1)[0]); + game = reactionAction(game, 'emperor', { type: 'nexusCardChoice', turn: game.turn, + card: cards.hands.emperor, choice: 'draw', ownRedraws: 0 }); + game = advanceClassicIndependentNexus(game, state => state.phase === 5 && + state.active === 'atreides' && classicDiscoveryNexusClean(state)); + const destination = TERRITORIES.find(place => place.type === 'stronghold' && + !place.sectors.includes(game.storm) && game.players.every(player => + Object.keys(player.forces).every(key => !key.startsWith(`${place.id}:`)))); + assert.ok(destination); + const before = structuredClone(game), key = `${destination.id}:${destination.sectors[0]}`; + const sources = homeworlds ? nativeShipmentSources(viewGame(game, 'atreides'), 2, 0) : undefined; + if (homeworlds) assert.ok(sources); + game = reactionAction(game, 'atreides', { type: 'ship', territory: destination.id, + sector: destination.sectors[0], amount: 2, elite: 0, allyPayment: 0, smuggler: true, + ...(sources ? { homeworldSources: sources } : {}) }); + game = advanceClassicIndependentNexus(game, state => !!state.pendingGuildBetrayal && + classicDiscoveryNexusClean(state)); + return { game, before, key }; +} + +void test('ordinary Smuggler ships two physical counters for one spice through the distinct Guild holder claim', () => { + const f = smugglerFeeGame(false), before = reactionPlayer(f.before, 'atreides'); + assert.equal(f.game.pendingGuildBetrayal!.invoice.price, 1); + assert.equal(reactionPlayer(f.game, 'atreides').reserves, before.reserves); + assert.equal(reactionPlayer(f.game, 'atreides').forces[f.key] ?? 0, 0); + const game = reactionAction(f.game, 'emperor', { type: 'guildBetrayalUse', + event: f.game.pendingGuildBetrayal!.invoice.event }); + assert.equal(reactionPlayer(game, 'atreides').spice, before.spice - 1); + assert.equal(reactionPlayer(game, 'atreides').reserves, before.reserves - 2); + assert.equal(reactionPlayer(game, 'atreides').forces[f.key], 2); + assert.equal(reactionPlayer(game, 'emperor').spice, reactionPlayer(f.before, 'emperor').spice + 1); + assert.equal(reactionPlayer(game, 'guild').spice, reactionPlayer(f.before, 'guild').spice); + assert.deepEqual(game.nexusCards!.cards!.discard, ['guild']); +}); + +void test('Homeworld Smuggler withdraws both Caladan counters only after Pass and pays the original discounted Guild invoice', () => { + const f = smugglerFeeGame(true), before = reactionPlayer(f.before, 'atreides'); + const caladan = (game: typeof f.game) => viewGame(game, 'atreides').homeworlds!.worlds! + .find(world => world.id === 'homeworld:atreides')!.forces.atreides; + assert.deepEqual(caladan(f.game), { normal: 10, elite: 0 }); + assert.equal(f.game.pendingGuildBetrayal!.invoice.price, 1); + let game = reactionAction(f.game, 'emperor', { type: 'guildBetrayalPass', + event: f.game.pendingGuildBetrayal!.invoice.event }); + assert.deepEqual(caladan(game), { normal: 10, elite: 0 }); + assert.equal(reactionPlayer(game, 'atreides').forces[f.key] ?? 0, 0); + game = reactionAction(game, 'atreides', { type: 'guildBetrayalPass', + event: game.pendingGuildBetrayal!.invoice.event }); + assert.deepEqual(caladan(game), { normal: 8, elite: 0 }); + assert.equal(reactionPlayer(game, 'atreides').reserves, before.reserves - 2); + assert.equal(reactionPlayer(game, 'atreides').forces[f.key], 2); + assert.equal(reactionPlayer(game, 'atreides').spice, before.spice - 1); + assert.equal(reactionPlayer(game, 'guild').spice, reactionPlayer(f.before, 'guild').spice + 1); + assert.equal(reactionPlayer(game, 'emperor').spice, reactionPlayer(f.before, 'emperor').spice); + assert.equal(game.nexusCards!.cards!.hands.emperor, 'guild'); + assert.deepEqual(game.nexusCards!.cards!.discard, []); +}); void test('ordinary base33 all14 supports the native declared-Traitor cancellation and real Mentat draw', () => { const f = createIndependentHarkonnenCall({ independent: false, advanced: true, skills: true }); diff --git a/tests/deferred-movement-arrival-fixture.ts b/tests/deferred-movement-arrival-fixture.ts index e1ad878a..7ccd416a 100644 --- a/tests/deferred-movement-arrival-fixture.ts +++ b/tests/deferred-movement-arrival-fixture.ts @@ -3,6 +3,7 @@ import { applyAction, createGame, initializeFactionExpansionsGameForAudit, + initializeHomeworldOccupationGameForAudit, joinGame, newPlayer, viewGame, @@ -69,10 +70,12 @@ export function holdArrivalCard(g: Game, id: string, name: string) { arrivalPlayer(g, id).hand.push(card); return card.id; } -/** Real expansion setup inventories, then the saved failure's focused movement topology. */ +/** Real expansion setup inventories, then the saved failure's focused movement topology. + * Homeworlds compose with this roster through the fresh occupation audit. */ export function movementArrivalGame( mover: FactionId = 'tleilaxu', tokens: 'both' | 'ambassador' | 'terror' | 'none' = 'both', + homeworlds = false, ) { let g = createGame('DEFERRED', newPlayer('ecaz', 'Ecaz', 'ecaz'), true, [ 'ix', @@ -82,8 +85,11 @@ export function movementArrivalGame( for (const faction of [mover, 'choam', 'moritani', 'richese'] as FactionId[]) joinGame(g, newPlayer(faction, faction, faction)); if (mover !== 'ixians') joinGame(g, newPlayer('ixians', 'Ixians', 'ixians')); + if (homeworlds) g.homeworlds = { custody: null }; for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); - g = initializeFactionExpansionsGameForAudit(g); + g = homeworlds + ? initializeHomeworldOccupationGameForAudit(g) + : initializeFactionExpansionsGameForAudit(g); for (let step = 0; g.status === 'setup' && step < 30; step++) { let next: Game | undefined; for (const p of g.players) { diff --git a/tests/discovery-standalone-nexus-e3-skills-runtime.test.ts b/tests/discovery-standalone-nexus-e3-skills-runtime.test.ts index 7365fd27..fd319dd9 100644 --- a/tests/discovery-standalone-nexus-e3-skills-runtime.test.ts +++ b/tests/discovery-standalone-nexus-e3-skills-runtime.test.ts @@ -261,12 +261,18 @@ void test('Advanced Moritani after-loss assassination kills the actual unused tr const reserve = [...pending.traitorReserve!], held = [...player(pending, f.owner).traitors]; const quote = viewGame(pending, f.owner).moritaniAssassinate!.pending!.cards.find(c => c.card === target.id)!; assert.equal(quote.bounty, 3); - const killed = step(pending, { actor: f.owner, action }); + let killed = step(pending, { actor: f.owner, action }); assert.equal(player(killed, f.opponent).leaders.find(l => l.id === target.id)!.dead, true); assert.equal(player(killed, f.opponent).leaders.find(l => l.id === target.id)!.deaths, target.deaths + 1); assert.equal(player(killed, f.owner).spice, player(pending, f.owner).spice + 3); assert.equal(killed.leaderSkills!.assignments.some(a => a.leader === target.id), false); assert.equal(killed.leaderSkills!.deck.filter(s => s === originalSkill).length, 1); + // The originally dealt hand may hold a Karama, so the after-loss window legally + // offers the native special denial before the winner cleanup; decline it through + // the real current decision exactly like the printed opportunity. + if (killed.decision?.kind === 'moritaniSpecialKarama') + killed = step(killed, { actor: killed.decision.player, + action: { type: 'decision', event: killed.decision.event, decline: true } }); assert.equal(killed.decision?.kind, 'battleCards'); assert.equal(killed.decision!.player, f.opponent); assert.deepEqual(killed.techTokens, pending.techTokens); let done = finish(step(killed, { actor: f.opponent, action: { type: 'decision', discard: [battle.enemyCard!] } })); @@ -324,9 +330,19 @@ void test('all four minimal policies act for the eligible native Cunning/assassi let game = step(structuredClone(pending), { actor: pending.decision!.player, action }); assert.equal(player(game, moritani.opponent).leaders.find(l => l.id === moritani.target)!.dead, true); assert.equal(player(game, moritani.owner).spice, player(pending, moritani.owner).spice + 3); - assert.equal(game.decision?.kind, 'battleCards'); - const cleanupActor = game.decision!.player, cleanup = policy(game, cleanupActor, difficulty)[0]; assert.ok(cleanup); - game = finish(step(game, { actor: cleanupActor, action: cleanup })); + // A dealt Karama may open the native denial before cleanup. Follow the + // actual owned policy choice without pinning an incidental decline shape. + if (game.decision?.kind === 'moritaniSpecialKarama') { + const karamaActor = game.decision.player; + const karama = policy(game, karamaActor, difficulty)[0]; assert.ok(karama); + game = step(game, { actor: karamaActor, action: karama }); + } + if (game.decision?.kind === 'battleCards') { + const cleanupActor = game.decision.player, cleanup = policy(game, cleanupActor, difficulty)[0]; + assert.ok(cleanup); + game = step(game, { actor: cleanupActor, action: cleanup }); + } + game = finish(game); assert.equal(game.battle, null); assert.equal(player(game, moritani.owner).hand.some(c => c.id === battle.card), false); game = advance(game, g => g.decision?.kind === 'moritaniPlacement'); assert.equal(player(game, moritani.owner).traitors.includes(moritani.target!), false); diff --git a/tests/faction-games-cli.test.ts b/tests/faction-games-cli.test.ts index 62aceb6b..dc5f625d 100644 --- a/tests/faction-games-cli.test.ts +++ b/tests/faction-games-cli.test.ts @@ -15,6 +15,7 @@ import { join } from 'node:path'; import type { Game } from '../game/engine'; import { createTechTokens } from '../game/tech-tokens'; +import { sampleInventory, verifySampleCustody } from '../tools/sample-custody'; const root = new URL('../', import.meta.url).pathname; const cli = new URL('../tools/faction-games.ts', import.meta.url).pathname; type CliResult = { @@ -141,21 +142,85 @@ void test('Ecaz card samples keep all three physical identities through saved re assert.equal(result.status, 1); assertFailedEvidence(out, 'ecaz-treachery-2-advanced', 1137, false); const snapshot = join(out, 'failed-ecaz-treachery-2-advanced.json'); - const game = json<{ - ecazTreachery: boolean; - deck: { id: string }[]; - discard: { id: string }[]; - players: { hand: { id: string }[] }[]; - }>(snapshot); - assert.equal(game.ecazTreachery, true); + const game = json(snapshot); assert.deepEqual( [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)] .map(card => card.id).filter(id => id.startsWith('ecaz-')).sort(), ['ecaz-harass-withdraw', 'ecaz-recruits', 'ecaz-reinforcements'], ); - const resumed = join(area, 'resumed'); - assert.equal(run(resumed, '--resume', snapshot, '--seed', '1000', '--max-actions', '1').status, 1); - assertFailedEvidence(resumed, 'ecaz-treachery-2-advanced', 1137, true); + const continued = join(area, 'ecaz-cards-resumed'); + assert.equal(run(continued, '--resume', snapshot, '--seed', '1000', '--max-actions', '1').status, 1); + assertFailedEvidence(continued, 'ecaz-treachery-2-advanced', 1137, true); + const continuedGame = json(join(continued, 'failed-ecaz-treachery-2-advanced.json')); + verifySampleCustody(continuedGame, sampleInventory(game), game); + assert.deepEqual( + [...continuedGame.deck, ...continuedGame.discard, ...continuedGame.players.flatMap(player => player.hand)] + .map(card => card.id).filter(id => id.startsWith('ecaz-')).sort(), + ['ecaz-harass-withdraw', 'ecaz-recruits', 'ecaz-reinforcements'], + ); + // Compatibility fixture for older samples: no native-power state or history + // existed at this setup point. Resume must preserve that absence, not retrofit it. + const older = structuredClone(game); + delete older.ecazOccupyPreview; + delete older.moritaniAssassinatePreview; + delete older.moritaniAssassinate; + delete older.moritaniAssassinateCallEvents; + const olderSnapshot = join(area, 'older-ecaz-cards.json'); + writeFileSync(olderSnapshot, JSON.stringify(older), { mode: 0o600 }); + const olderOut = join(area, 'older-ecaz-cards-resumed'); + assert.equal(run(olderOut, '--resume', olderSnapshot, '--seed', '1000', '--max-actions', '1').status, 1); + assertFailedEvidence(olderOut, 'ecaz-treachery-2-advanced', 1137, true); + const olderContinued = json(join(olderOut, 'failed-ecaz-treachery-2-advanced.json')); + for (const field of ['ecazOccupyPreview', 'moritaniAssassinatePreview', 'moritaniAssassinate', + 'moritaniAssassinateResume', 'moritaniAssassinateCallEvents'] as const) + assert.equal(olderContinued[field], undefined); + verifySampleCustody(olderContinued, sampleInventory(older), older); +}); + +void test('original Ecaz snapshots continue played state with native custody, not fresh setup', (t) => { + const area = temporary(t); + for (const [profile, players, seed] of [ + ['ecaz-treachery', '2', '1000'], ['ecaz-roster', '4', '20260927'], + ] as const) { + const name = `${profile}-${players}-advanced`; + const initial = join(area, `${profile}-initial`); + assert.equal(run(initial, '--profile', profile, '--players', players, '--rules', 'advanced', + '--seed', seed, '--max-actions', '20').status, 1); + const snapshot = join(initial, `failed-${name}.json`); + const saved = json(snapshot); + const original = readFileSync(snapshot, 'utf8'); + assert.equal(saved.status, 'playing'); + assert.ok(saved.moritaniAssassinate); + const continued = join(area, `${profile}-continued`); + assert.equal(run(continued, '--resume', snapshot, '--seed', seed, '--max-actions', '1').status, 1); + const report = assertFailedEvidence(continued, name, Number(seed) + (players === '2' ? 137 : 83), true); + assert.equal(report.options.profile, profile); + assert.equal(report.options.players, Number(players)); + assert.equal(report.options.resume?.path, snapshot); + const trace = json<{ turn: number; phase: number; setup: string | null; + decision: string | null; outcome: string }[]>(join(continued, `trace-${name}.json`)); + assert.equal(trace[0].turn, saved.turn); + assert.equal(trace[0].phase, saved.phase); + assert.equal(trace[0].setup, saved.setupStage ?? null); + assert.equal(trace[0].decision, saved.decision?.kind ?? null); + assert.equal(trace[0].outcome, 'accepted'); + const after = json(join(continued, `failed-${name}.json`)); + verifySampleCustody(after, sampleInventory(saved), saved); + for (const field of ['ecazAmbassadors', 'moritaniTerror', 'dukeVidal', + 'moritaniAssassinate', 'moritaniAssassinateCallEvents'] as const) + assert.deepEqual(after[field], saved[field], `${profile}: original ${field} custody`); + assert.equal(readFileSync(snapshot, 'utf8'), original); + + for (const field of ['ecazOccupyPreview', 'moritaniAssassinatePreview', 'moritaniAssassinate'] as const) { + const partial = structuredClone(saved); + delete partial[field]; + const path = join(area, `${profile}-missing-${field}.json`); + writeFileSync(path, JSON.stringify(partial), { mode: 0o600 }); + const rejected = join(area, `${profile}-missing-${field}`); + assert.equal(run(rejected, '--resume', path, '--max-actions', '1').status, 1); + assert.equal(existsSync(rejected), false); + } + } }); void test('Advanced Moritani assassination samples retain their preview and physical Traitor pool on resume', (t) => { @@ -402,7 +467,7 @@ void test('expansion-roster snapshots resume their own Advanced games rather tha const snapshot = join(initial, `failed-${name}.json`); const continued = join(area, `${profile}-continued`); const resumed = run(continued, '--resume', snapshot, '--seed', seed); - assert.equal(resumed.status, 0, resumed.stderr); + assert.equal(resumed.status, 0, `${profile}: ${resumed.stderr}\n${resumed.stdout}`); const report = json(join(continued, 'report.json')); const result = json<{ results: (CliResult & { restores: number; rejected: Record })[] }>(join(continued, 'results.json')).results[0]; assert.equal(report.status, 'passed'); diff --git a/tests/fixture-homeworld-classic-nexus-skills.ts b/tests/fixture-homeworld-classic-nexus-skills.ts index 865ffc1b..53ad2de1 100644 --- a/tests/fixture-homeworld-classic-nexus-skills.ts +++ b/tests/fixture-homeworld-classic-nexus-skills.ts @@ -44,7 +44,8 @@ export type ClassicHomeworldNexusSkillsFixture = ClassicHomeworldSkillsSetup & { }; export const classicHomeworldNexusSkillsPlayer = classicHomeworldSkillsPlayer; export const classicHomeworldNexusSkillsClean = (game: Game) => - !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard; + !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard && + !game.pendingGuildBetrayal; /** Original private Nexus opt-in, not a fabricated player action. The returned * lobby is also usable by the integration owner's original private QA route. */ @@ -91,6 +92,16 @@ export function assertClassicHomeworldNexusSkillsInventory(game: Game): void { } export function nextClassicHomeworldNexusSkillsStep(game: Game): ClassicHomeworldNexusSkillsStep | null { if (!game.phaseOpening && !game.response) { + // Homeworld shipments through the Guild fee gate now open the native + // Betrayal acknowledgement window for classic Nexus core games; decline it. + const betrayal = game.pendingGuildBetrayal; + if (betrayal) { + const actor = betrayal.required.find(id => !betrayal.passed.includes(id))!; + return { + actor, + action: { type: 'guildBetrayalPass', event: betrayal.invoice.event }, + }; + } const decision = game.decision; if (decision?.kind === 'guildShipment') return { actor: decision.player, action: { type: 'decision', allow: true } }; if (decision?.kind === 'homeworldShipmentGuild') return { actor: decision.player, action: { type: 'decision', event: decision.event, allow: true } }; diff --git a/tests/fixture-nexus-advisor-fresh.ts b/tests/fixture-nexus-advisor-fresh.ts index 986f15ff..43e7408d 100644 --- a/tests/fixture-nexus-advisor-fresh.ts +++ b/tests/fixture-nexus-advisor-fresh.ts @@ -38,7 +38,8 @@ export type NexusAdvisorFreshFixture = { undealtOrder: string[]; }; }; -const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision; +const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && + !game.pendingGuildBetrayal; export function nexusAdvisorFreshPlayer(game: Game, actor: string) { const player = game.players.find(p => p.id === actor); assert.ok(player, `Missing original advisor seat ${actor}`); @@ -58,8 +59,18 @@ function entropy(producer: () => T): T { } } function nativeStep(game: Game): StrongholdFactionsNativeStep { + // Native reaction states now initialize at fresh setup for classic core games: + // finish the real Guild Betrayal acknowledgement and purchased-card windows. + const betrayal = game.pendingGuildBetrayal; + if (betrayal) { + const seat = betrayal.required.find(id => !betrayal.passed.includes(id)); + assert.ok(seat, 'The pending Guild Betrayal window must retain an unfinished responder'); + return { actor: seat, action: { type: 'guildBetrayalPass', event: betrayal.invoice.event } }; + } if (!game.phaseOpening && !game.response) { const decision = game.decision; + if (decision?.kind === 'nexusIxianReplacement') + return { actor: decision.player, action: { type: 'nexusIxianReplacementPass', event: decision.event } }; if (decision?.kind === 'guildTiming') return { actor: decision.player, action: { type: 'decision', take: game.turn === 2 } }; // Preserve original advisors through the earlier mandatory declaration. diff --git a/tests/fixture-nexus-borrowed-voice.ts b/tests/fixture-nexus-borrowed-voice.ts index a88db14f..c11650fb 100644 --- a/tests/fixture-nexus-borrowed-voice.ts +++ b/tests/fixture-nexus-borrowed-voice.ts @@ -60,7 +60,7 @@ export function nexusBorrowedVoicePlayer(game: Game, id: string): Player { return player; } export const nexusBorrowedVoiceClean = (game: Game): boolean => - !game.phaseOpening && !game.response && !game.decision; + !game.phaseOpening && !game.response && !game.decision && !game.pendingGuildBetrayal; /** Physical census of the original pieces; no module owner is assumed. */ @@ -107,6 +107,17 @@ type Driver = { * the first-auction bid, the closing Nexus draws and the battle chooser. */ function nextStep(game: Game, driver: Driver): NexusBorrowedVoiceStep | null { if (game.status === 'finished') return null; + // Native reaction states now initialize at fresh setup for classic core games: + // finish the real Guild Betrayal acknowledgement and purchased-card windows. + const betrayal = game.pendingGuildBetrayal; + if (betrayal) { + const seat = betrayal.required.find(id => !betrayal.passed.includes(id)); + assert.ok(seat, 'The pending Guild Betrayal window must retain an unfinished responder'); + return { actor: seat, action: { type: 'guildBetrayalPass', event: betrayal.invoice.event } }; + } + if (game.decision?.kind === 'nexusIxianReplacement') + return { actor: game.decision.player, + action: { type: 'nexusIxianReplacementPass', event: game.decision.event } }; if (!nexusBorrowedVoiceClean(game)) return nextStrongholdFactionsNativeStep(game); // The closing Nexus card choice is private; the fixture draws the staged physical card. if (nexusBorrowedVoiceClean(game) && game.nexusCards?.phase?.stage === 'drawing') { diff --git a/tests/fixture-nexus-fremen-protection.ts b/tests/fixture-nexus-fremen-protection.ts index acc72ba5..8be95044 100644 --- a/tests/fixture-nexus-fremen-protection.ts +++ b/tests/fixture-nexus-fremen-protection.ts @@ -54,7 +54,7 @@ export function nexusFremenProtectionPlayer(game: Game, id: string): Player { } export const nexusFremenProtectionClean = (game: Game): boolean => !game.phaseOpening && !game.response && !game.decision && !game.truthtrance && - !game.pendingTreacheryDiscard; + !game.pendingTreacheryDiscard && !game.pendingGuildBetrayal; /** Scalar shuffle lotteries preserve printed order; UUID entropy stays native. */ function producerEntropy(run: () => T): T { @@ -79,12 +79,23 @@ function producerEntropy(run: () => T): T { * so no seat ever needs an invented sealed plan. */ export function nextNexusFremenProtectionNativeStep(game: Game): NexusFremenProtectionStep | null { if (game.status === 'finished') return null; + // Native reaction states now initialize at fresh setup for classic core games: + // finish the real Guild Betrayal acknowledgement and purchased-card windows. + const betrayal = game.pendingGuildBetrayal; + if (betrayal) { + const seat = betrayal.required.find(id => !betrayal.passed.includes(id)); + assert.ok(seat, 'The pending Guild Betrayal window must retain an unfinished responder'); + return { actor: seat, action: { type: 'guildBetrayalPass', event: betrayal.invoice.event } }; + } if (game.phaseOpening || game.response || game.truthtrance || game.pendingTreacheryDiscard) return nextStrongholdFactionsNativeStep(game); const decision = game.decision; if (decision?.kind === 'nexusFremenProtection') return { actor: decision.player, action: { type: 'nexusFremenProtection', event: decision.event, use: false } }; + if (decision?.kind === 'nexusIxianReplacement') + return { actor: decision.player, + action: { type: 'nexusIxianReplacementPass', event: decision.event } }; if (decision?.kind === 'advisor' || decision?.kind === 'intrusion' || decision?.kind === 'advisorBattle') return { actor: decision.player, action: { type: 'decision', accept: false } }; diff --git a/tests/homeworld-classic-nexus-skills-runtime.test.ts b/tests/homeworld-classic-nexus-skills-runtime.test.ts index 9d0e9b16..c49ef785 100644 --- a/tests/homeworld-classic-nexus-skills-runtime.test.ts +++ b/tests/homeworld-classic-nexus-skills-runtime.test.ts @@ -164,6 +164,12 @@ for (const kind of ['native-suk', 'visitor-suk'] as const) { for (const advanced of [false, true]) { void test(`${advanced ? 'Advanced typed' : 'Basic ordinary'} real zero-save Suk losses return through the Fremen quota and normal Homeworld revival at the next actual Revival`, () => { const f = fixture({ advanced, discovery: advanced, tech: true, strongholds: advanced }); + const shipper = f.shipment.actor; + assert.equal(f.afterShipment.pendingGuildBetrayal ?? null, null); + assert.equal(player(f.afterShipment, shipper).spice, player(f.beforeShipment, shipper).spice - 4); + assert.equal(player(f.afterShipment, shipper).reserves, player(f.beforeShipment, shipper).reserves - 4); + assert.deepEqual(pool(f.afterShipment, shipper, f.territory), + { normal: advanced ? 3 : 4, elite: advanced ? 1 : 0 }); const pending = settle(reveal(f), 'suk'); assert.ok(pending.decision?.kind === 'sukRescue'); const choice = pending.decision.options.findIndex(option => !option.normal && !option.elite && !option.kept); assert.ok(choice >= 0, 'The skilled original human option may genuinely decline all rescue.'); diff --git a/tests/homeworld-payment-income-engine.test.ts b/tests/homeworld-payment-income-engine.test.ts index f4e97644..271b2157 100644 --- a/tests/homeworld-payment-income-engine.test.ts +++ b/tests/homeworld-payment-income-engine.test.ts @@ -181,7 +181,9 @@ function settle(g: Game, responder: string, karama: string, cancel: boolean) { ? { type: 'card', mode: 'cancel', card: karama } : { type: 'passResponse' }, ); - assert.deepEqual(g, original); + // The auction income response now stamps optional Bureaucrat fields whose + // absent values are JSON-invisible; compare the serialized forms. + assert.deepEqual(reload(g), original); inventory(done); for ( let n = 0; diff --git a/tests/ix-skills-movement.test.ts b/tests/ix-skills-movement.test.ts index 89620b9d..15837f87 100644 --- a/tests/ix-skills-movement.test.ts +++ b/tests/ix-skills-movement.test.ts @@ -137,12 +137,14 @@ void test('genuine ready Ix lobbies initialize the supported native module profi 'The supported native module profile initializes.'); assert.deepEqual(g, before, 'The authoritative lobby is never mutated.'); } - // A combined CHOAM expansion still rejects. + // The intentional composition change admits a combined CHOAM deck too; the + // initializer keeps producing Skills state without mutating the lobby. const combined = lobby(); combined.expansions.push('choam'); const before = structuredClone(combined); - assert.throws(() => initializeLeaderSkillsGameForAudit(combined)); - assert.deepEqual(combined, before); + assert.ok(initializeLeaderSkillsGameForAudit(combined).leaderSkills, + 'The combined selected-deck profile initializes.'); + assert.deepEqual(combined, before, 'The authoritative lobby is never mutated.'); }); void test('Planetologist gives selected suboids range two independently and cyborg range three through saved native permission', () => { diff --git a/tests/nexus-advisor-storm-runtime.test.ts b/tests/nexus-advisor-storm-runtime.test.ts index 398dd103..bc1d025b 100644 --- a/tests/nexus-advisor-storm-runtime.test.ts +++ b/tests/nexus-advisor-storm-runtime.test.ts @@ -28,6 +28,14 @@ function resources(game: Game) { } function settle(game: Game): Game { for (let count = 0; count < 80; count++) { + // Native Guild Betrayal now initializes at fresh setup; the paid polar + // sink shipment opens its acknowledgement window before it may commit. + const betrayal = game.pendingGuildBetrayal; + if (betrayal) { + const seat = betrayal.required.find(id => !betrayal.passed.includes(id))!; + game = applyAction(game, seat, { type: 'guildBetrayalPass', event: betrayal.invoice.event }); + continue; + } if (!game.response && !game.decision && !game.phaseOpening) return game; const step = nextStrongholdFactionsNativeStep(game); assert.ok(step, 'Only original responses and decisions may settle the declaration'); diff --git a/tests/nexus-emperor-purchase-engine.test.ts b/tests/nexus-emperor-purchase-engine.test.ts index 8c779920..ceb614bd 100644 --- a/tests/nexus-emperor-purchase-engine.test.ts +++ b/tests/nexus-emperor-purchase-engine.test.ts @@ -69,7 +69,11 @@ void test('a prior normal purchase cannot authorize spending Emperor Nexus on a reject(revised, owner, first, /current Emperor Nexus/i); g = applyAction(g, owner, { type: 'decision', karama: false }); for (let i = 0; g.decision?.kind !== 'auctionPayment' && i < 20; i++) { - if (g.auction?.active === owner && !g.auction.bid) + // Native purchased-card choices now initialize at fresh setup for classic + // core games; the paid lot opens its native replacement window first. + if (g.decision?.kind === 'nexusIxianReplacement') + g = applyAction(g, g.decision.player, { type: 'nexusIxianReplacementPass', event: g.decision.event }); + else if (g.auction?.active === owner && !g.auction.bid) g = applyAction(g, owner, { type: 'bid', amount: 2 }); else if (g.auction) g = applyAction(g, g.auction.active, { type: 'passBid' }); @@ -90,8 +94,10 @@ void test('invalid normal-lot custody and full winning hand cannot spend the phy const action = { type: 'nexusEmperorPurchase', event: viewGame(g, owner).nexusEmperorSecretAlly!.event }; const duplicate = nexusReload(g); duplicate.deck.push(structuredClone(g.auction!.cards[g.auction!.index])); - assert.match(viewGame(duplicate, owner).nexusEmperorSecretAlly!.purchase!.blocked!, /conflicting physical custody/); - reject(duplicate, owner, action, /conflicting physical custody/); + // Duplicate card custody is now rejected by the native purchased-card + // integrity check before the Emperor purchase gate is even consulted. + assert.throws(() => viewGame(duplicate, owner), /single physical custody/); + reject(duplicate, owner, action, /single physical custody/); const full = nexusReload(g); full.players.find(p => p.id === owner)!.hand.push(...full.deck.splice(0, 4)); assert.match(viewGame(full, owner).nexusEmperorSecretAlly!.purchase!.blocked!, /room for the committed/); diff --git a/tests/nexus-guild-betrayal-controls.test.tsx b/tests/nexus-guild-betrayal-controls.test.tsx index e5f959cb..08dd72b4 100644 --- a/tests/nexus-guild-betrayal-controls.test.tsx +++ b/tests/nexus-guild-betrayal-controls.test.tsx @@ -157,8 +157,6 @@ void test('actual GameTable reserves underlying shipment, voluntary and hand con })); const html = table(own); assert.match(html, /aria-label="Guild Nexus Betrayal response"/); - assert.match(html, /Guild Nexus Betrayal development preview/); - assert.match(html, /href="\/rules\?topic=nexus-guild-betrayal#nexus-guild-betrayal"/); assert.doesNotMatch(html, /]*>[^<]*(?:Ship |Confirm shipment|End movement|Play Karama|Make a bribe|Pledge|Search)/i); assert.match(table(own, true), /disabled=""[^>]*>Use Guild Nexus/); for (const restriction of ['paused', 'closed', 'autopilot'] as const) { diff --git a/tests/no-field-reinforcements.test.ts b/tests/no-field-reinforcements.test.ts index dae2de6c..5072be89 100644 --- a/tests/no-field-reinforcements.test.ts +++ b/tests/no-field-reinforcements.test.ts @@ -176,11 +176,16 @@ void test('An Ecaz card user cannot fund Reinforcements from its Richese ally af assertConserved(fixture, before); }); -void test('Original Advanced native inventory without the selected Occupy profile still cannot use Reinforcements', () => { +void test('Original Advanced native inventory without the selected Occupy profile now uses Reinforcements', () => { + // The intentional composition change admits that original inventory on its + // own, so Reinforcements no longer requires the selected Occupy label. const fixture = noFieldReinforcementsFixture({ marker: 3, richeseReserves: 6, omitPreview: true }); const before = structuredClone(fixture.game); - assert.ok(viewGame(before, fixture.owner).battle!.reinforcements!.blocked); - assert.throws(() => applyAction(before, fixture.owner, fixture.plans.reinforcements), RuleError); + const preview = viewGame(before, fixture.owner).battle!.reinforcements!; + assert.equal(preview.blocked, null); + assert.equal(preview.normal, 3); + const sealed = applyAction(before, fixture.owner, fixture.plans.reinforcements); + assert.deepEqual(Object.keys(sealed.battle!.plans!), [fixture.owner]); assert.deepEqual(before, fixture.game); assertConserved(fixture, before); }); diff --git a/tests/prototype-room.test.ts b/tests/prototype-room.test.ts index 81878011..d9d728b2 100644 --- a/tests/prototype-room.test.ts +++ b/tests/prototype-room.test.ts @@ -415,7 +415,7 @@ for (const advanced of [false, true]) { }); } -void test('Ixian Nexus replacement rejects unready, expansion, native Ixian and Homeworld lobbies without reconfiguring them', t => { +void test('Ixian Nexus replacement rejects unready, expansion and native Ixian lobbies without reconfiguring them', t => { const { db } = fixture(t); const classic = createGame('PROTOTYP', newPlayer('i', 'Atreides', 'atreides'), true); joinGame(classic, newPlayer('e', 'Emperor', 'emperor')); @@ -427,10 +427,6 @@ void test('Ixian Nexus replacement rejects unready, expansion, native Ixian and const native = createGame('PROTOTYP', newPlayer('i', 'Ixians', 'ixians'), true, ['ix']); joinGame(native, newPlayer('e', 'Emperor', 'emperor')); for (const p of native.players) p.ready = true; - const homeworld = applyAction(classic, classic.host, { type: 'homeworlds', enabled: true }); - for (const p of homeworld.players) p.ready = true; - // Homeworlds now compose with the shared classic Nexus core; the - // homeworld case is intentionally admitted through the normal start seam. for (const game of [unready, expansion, native]) { db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game), game.code); const rooms = db.prepare('SELECT * FROM rooms ORDER BY code').all(); @@ -735,9 +731,8 @@ void test('Harkonnen Betrayal rejects incompatible lobbies, overlays and ordinar game => { game.players.splice(1); }, game => { game.players.push(...Array.from({ length: 4 }, (_, index) => newPlayer(`extra${index}`, `Extra ${index}`, 'guild'))); }, ]; - // Original Tech3+/Advanced Strongholds2+/Homeworlds now compose with the - // shared classic Nexus core; these modules are intentionally admitted - // through the normal start seam rather than this explicit prototype. + // Original Tech3+/Advanced Strongholds2+/Homeworlds are now supported by + // the shared classic Nexus core, so they are not rejection cases. for (const game of changes.map(change => { const game = structuredClone(native); change(game); diff --git a/tests/sandmaster-movement-controls.test.tsx b/tests/sandmaster-movement-controls.test.tsx index dbd4a4b8..c16b2761 100644 --- a/tests/sandmaster-movement-controls.test.tsx +++ b/tests/sandmaster-movement-controls.test.tsx @@ -78,13 +78,20 @@ function markup(game: Game, viewer = 'p', busy = false) { ); } -void test('Sandmaster controls remain absent from other seats and unsupported module profiles', () => { +void test('Sandmaster controls stay absent from other seats and unsupported overlays while the combined ix deck renders', () => { const game = fixture(); assert.equal(markup(game, 'o'), ''); + // The intentional composition change admits the selected ix deck alongside + // classic seats, so the mover's controls now render for that combination. const combined = fixture(); combined.expansions = ['ix']; - assert.equal(markup(combined), ''); + assert.match(markup(combined), /Sandmaster movement/); + + const unsupported = fixture(); + unsupported.expansions = ['ix']; + unsupported.kullPreview = true; + assert.equal(markup(unsupported), ''); }); void test('drafts preserve the ordinary move, accept a connected alternate and allow collecting or declining', () => { @@ -124,7 +131,7 @@ void test('drafts preserve the ordinary move, accept a connected alternate and a ); }); -void test('all four bot profiles add legal collection only to ordinary single-source candidates', () => { +void test('all four bot profiles add legal collection to ordinary and admitted combined-deck candidates', () => { for (const difficulty of DIFFICULTIES) { const game = fixture(); const view = viewGame(game, 'p'); @@ -148,12 +155,30 @@ void test('all four bot profiles add legal collection only to ordinary single-so assert.equal(completed.players[0].spice, game.players[0].spice + selected.length, difficulty); for (const key of selected) assert.equal(completed.spice[key], game.spice[key] - 1, difficulty); - const combined = viewGame(fixture(), 'p'); + // The intentional composition change admits the selected ix deck, so the + // same legal collection candidates are emitted and settle there too. + const combinedGame = fixture(); + combinedGame.expansions = ['ix']; + const combined = viewGame(combinedGame, 'p'); combined.players.find((player) => player.id === 'p')!.bot = difficulty; - combined.expansions = ['ix']; - assert.ok( - botActions(combined).every((action) => !action.sandmaster), - `${difficulty} emitted Sandmaster in a gated module combination`, + const combinedCandidates = botActions(combined).filter( + (action) => action.type === 'move' && action.sandmaster, ); + assert.ok( + combinedCandidates.length, + `${difficulty} omitted Sandmaster collection in the combined ix deck`, + ); + const combinedDone = applyAction(combinedGame, 'p', combinedCandidates[0]); + const combinedChoice = combinedCandidates[0].sandmaster; + assert.ok(combinedChoice, difficulty); + const combinedSelected = (combinedChoice as { collect: string[] }).collect; + assert.equal(combinedDone.players[0].moved, 1, difficulty); + assert.equal( + combinedDone.players[0].spice, + combinedGame.players[0].spice + combinedSelected.length, + difficulty, + ); + for (const key of combinedSelected) + assert.equal(combinedDone.spice[key], combinedGame.spice[key] - 1, difficulty); } }); diff --git a/tests/spice-banker-controls.test.tsx b/tests/spice-banker-controls.test.tsx index 50ccfeaa..6f3f4d32 100644 --- a/tests/spice-banker-controls.test.tsx +++ b/tests/spice-banker-controls.test.tsx @@ -50,19 +50,11 @@ void test('Spice Banker renders a bounded separate own-spice commitment only for const html = renderControl(view, 'emperor-0', 2, 2); assert.match(html, /aria-label="Spice Banker battle commitment"/); assert.match(html, /id="battle-banker-spice"[^>]*max="2"[^>]*value="2"/); - assert.match( - html, - /Plan funding: 2 own spice for force support \+ 2 committed to Spice Banker = 4 own spice total/, - ); - assert.match( - html, - /Each committed spice adds 1 strength if this skilled leader survives/, - ); - assert.match(html, /commitment goes to the Bank after a win or loss/); - assert.match( - html, - /sole winner through a traitor call spends no Battle Plan spice/, - ); + const text = html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' '); + assert.match(text, /Plan funding: 2 own spice for force support \+ 2 committed to Spice Banker = 4 own spice total/); + assert.match(text, /Each committed spice adds 1 strength if this skilled leader survives/); + assert.match(text, /commitment goes to the Bank after a win or loss/); + assert.match(text, /sole winner through a traitor call spends no Battle Plan spice/); assert.equal(renderControl(view, 'emperor-1', 0, 3), ''); const faceUp = structuredClone(view); @@ -83,9 +75,10 @@ void test('Spice Banker renders a bounded separate own-spice commitment only for )!.controller = 'd'; assert.equal(renderControl(foreign, 'emperor-0', 0, 3), ''); - const combinedModule = structuredClone(view); - combinedModule.expansions = ['ix']; - assert.equal(renderControl(combinedModule, 'emperor-0', 0, 3), ''); + const combined = structuredClone(native); + combined.expansions = ['ix']; + assert.match(renderControl(viewGame(combined, 'a'), 'emperor-0', 2, 2), + /id="battle-banker-spice"[^>]*max="2"[^>]*value="2"/); const capturedGame = spiceBankerGame(false, true); capturedGame.players[0].spice = 3; @@ -174,46 +167,33 @@ void test('full-plan inspection and public reveal show the exact commitment with ); }); -void test('all four bot profiles reserve only remaining own spice and emit legal skilled plans', () => { - for (const [profile, expectedCap] of DIFFICULTIES.map( - (difficulty, index) => [difficulty, Math.min(index + 1, 3)] as const, - )) { +void test('all four bot profiles fund original and combined-deck Banker plans without double-spending force support', () => { + for (const combined of [false, true]) for (const profile of DIFFICULTIES) { const game = spiceBankerGame(true); + game.expansions = combined ? ['ix'] : []; game.players[0].spice = 4; const view = viewGame(game, 'a'); view.players.find((player) => player.id === 'a')!.bot = profile; const plans = botActions(view).filter( (action) => action.type === 'battlePlan' && action.leader === 'emperor-0', ); - assert.ok(plans.length, `${profile} omitted the skilled disc`); + const selected = plans.find(action => Number(action.bankerSpice ?? 0) > 0); + assert.ok(selected, `${profile} needs a legal positive Banker commitment`); for (const action of plans) { - const support = Number(action.support ?? 0); - assert.equal( - Number(action.bankerSpice ?? 0), - Math.min(expectedCap, Math.max(0, 4 - support)), - `${profile} did not reserve Banker spice after force support`, - ); + const support = Number(action.support ?? 0), banker = Number(action.bankerSpice ?? 0); + assert.ok(Number.isSafeInteger(banker) && banker >= 0 && banker <= 3); + assert.ok(support + banker <= 4, `${profile} exceeds its own available spice`); } - assert.doesNotThrow( - () => applyAction(game, 'a', plans[0]), - `${profile} emitted an illegal Spice Banker plan`, - ); - } -}); - -void test('bots do not emit a currently gated Banker commitment in a combined-module view', () => { - const game = spiceBankerGame(); - const view = viewGame(game, 'a'); - view.expansions = ['ix']; - for (const difficulty of DIFFICULTIES) { - view.players.find((player) => player.id === 'a')!.bot = difficulty; - const skilled = botActions(view).filter( - (action) => action.type === 'battlePlan' && action.leader === 'emperor-0', - ); - assert.ok(skilled.length); - assert.ok( - skilled.every((action) => !action.bankerSpice), - `${difficulty} emitted a gated Banker commitment`, - ); + let settled = applyAction(game, 'a', selected); + assert.equal(settled.players[0].spice, 4, 'Sealing alone does not collect the commitment.'); + settled = applyAction(settled, 'd', { type: 'battlePlan', leader: 'guild-1', dial: 0, support: 0 }); + settled = applyAction(settled, 'a', { type: 'traitorCall', call: false }); + settled = applyAction(settled, 'd', { type: 'traitorCall', call: false }); + // Plans without played cards finish cleanup automatically. Base play then + // collects Arrakeen's two spice; the ix deck first opens its native window. + const cityIncome = settled.phase === 7 && !settled.phaseOpening ? 2 : 0; + assert.ok(settled.phase === 6 || settled.phase === 7); + assert.equal(settled.players[0].spice, + 4 - Number(selected.support ?? 0) - Number(selected.bankerSpice ?? 0) + cityIncome); } }); diff --git a/tools/faction-games.ts b/tools/faction-games.ts index 6d9bebb2..238770e6 100644 --- a/tools/faction-games.ts +++ b/tools/faction-games.ts @@ -814,6 +814,19 @@ const assassinationScenario = (scenario: Scenario) => scenario.rules === 'advanc scenario.roster.includes('moritani') && (mixedE3NexusSampleProfile(scenario.profile) || pairedE3NexusSampleProfile(scenario.profile) || e3NexusSampleProfile(scenario.profile) || scenario.profile === 'moritani-assassinate' || discoveryProfile(scenario.profile) || nativeStrongholdProfile(scenario.profile) || nativeTechProfile(scenario.profile) || skillProfile(scenario.profile)); +function originalNativeFactionStateMatches(game: Game, scenario: Scenario) { + const noAssassination = game.moritaniAssassinatePreview === undefined && + game.moritaniAssassinate === undefined && game.moritaniAssassinateResume === undefined && + game.moritaniAssassinateCallEvents === undefined; + // Older fixed samples had neither native power. Preserve that saved absence; + // current setup supplies Occupy plus Advanced Moritani's original ability state. + return (game.ecazOccupyPreview === undefined && noAssassination) || + (game.ecazOccupyPreview === true && + (scenario.rules === 'advanced' && scenario.roster.includes('moritani') + ? game.moritaniAssassinatePreview === true && !!game.moritaniAssassinate + : noAssassination)); +} + type TraceEntry = { attempt: number; accepted: number; @@ -1102,12 +1115,14 @@ function resumedGame(path: string) { (homeworldTechSampleProfile(candidate.profile) || discoveryTechProfile(candidate.profile) || candidate.profile === 'skills-tech' || candidate.profile === 'skills-stronghold-tech' || nativeTechProfile(candidate.profile) || nexusTechProfile(candidate.profile)) === !!game.techTokens && (homeworldStrongholdSampleProfile(candidate.profile) || discoveryStrongholdProfile(candidate.profile) || nativeStrongholdProfile(candidate.profile) || strongholdSkillProfile(candidate.profile) || nexusStrongholdProfile(candidate.profile)) === !!game.strongholdCards && (candidate.profile === 'ecaz-treachery' || candidate.ecazTreachery === true) === !!game.ecazTreachery && - (originalNativeNexusSampleProfile(candidate.profile) - ? !!game.moritaniAssassinatePreview === !!game.moritaniAssassinate - : assassinationScenario(candidate) === !!game.moritaniAssassinatePreview && - assassinationScenario(candidate) === !!game.moritaniAssassinate) && - (originalNativeNexusSampleProfile(candidate.profile) || - (candidate.profile === 'ecaz-occupy' || ((mixedE3NexusSampleProfile(candidate.profile) || pairedE3NexusSampleProfile(candidate.profile) || e3NexusSampleProfile(candidate.profile) || homeworldModuleSampleProfile(candidate.profile) || nativeDiscoveryNexusSkillsProfile(candidate.profile) || nativeDiscoverySkillsProfile(candidate.profile) || nativeTechProfile(candidate.profile) || discoveryProfile(candidate.profile)) && candidate.roster.includes('ecaz'))) === !!game.ecazOccupyPreview) && + (['ecaz', 'combined', 'ecaz-roster', 'ecaz-treachery'].includes(candidate.profile) + ? originalNativeFactionStateMatches(game, candidate) + : (originalNativeNexusSampleProfile(candidate.profile) + ? !!game.moritaniAssassinatePreview === !!game.moritaniAssassinate + : assassinationScenario(candidate) === !!game.moritaniAssassinatePreview && + assassinationScenario(candidate) === !!game.moritaniAssassinate) && + (originalNativeNexusSampleProfile(candidate.profile) || + (candidate.profile === 'ecaz-occupy' || ((mixedE3NexusSampleProfile(candidate.profile) || pairedE3NexusSampleProfile(candidate.profile) || e3NexusSampleProfile(candidate.profile) || homeworldModuleSampleProfile(candidate.profile) || nativeDiscoveryNexusSkillsProfile(candidate.profile) || nativeDiscoverySkillsProfile(candidate.profile) || nativeTechProfile(candidate.profile) || discoveryProfile(candidate.profile)) && candidate.roster.includes('ecaz'))) === !!game.ecazOccupyPreview)) && candidate.rules === (game.advanced ? 'advanced' : 'basic') && JSON.stringify(candidate.expansions) === JSON.stringify(game.expansions) &&