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) &&