import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { runInNewContext } from 'node:vm'; import { webcrypto } from 'node:crypto'; import ts from 'typescript'; import { applyAction, createGame, newPlayer, normalizeAutomaticGame, viewGame, type Game, type Action, } from '../game/engine'; import { botActions, runBots } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { baseDeck, type Card } from '../game/cards'; import type { FactionId } from '../game/catalog'; import { createTechTokens } from '../game/tech-tokens'; import { createAuditorLeader, CHOAM_AUDITOR_ID } from '../game/choam-auditor'; const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!; const reload = (g: Game): Game => JSON.parse(JSON.stringify(g)); /** Test-only observation of the unchanged production inner dispatcher, before * its public wrapper drains automatic continuations. No runtime export or gate * is changed, and all resumed actions use the real production exports. */ const observed: { applyActionInner?: (g: Game, id: string, a: Action) => Game; finishResponse?: (g: Game, canceled: boolean) => void; } = {}; runInNewContext( ts.transpileModule( readFileSync(new URL('../game/engine.ts', import.meta.url), 'utf8') + '\nexport { applyActionInner, finishResponse };\n', { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, }, }, ).outputText, { exports: observed, require: createRequire(new URL('../game/engine.ts', import.meta.url)), crypto: webcrypto, structuredClone, TextEncoder, JSON, }, ); function inner(g: Game, id: string, a: Action) { const before = structuredClone(g); const result = reload(observed.applyActionInner!(g, id, a)); assert.deepEqual(g, before); return result; } function fixture(winner: FactionId = 'guild', extra?: 'moritani' | 'choam') { const g = createGame('BATTLEFRAME', newPlayer('w', 'Winner', winner), true); g.players.push(newPlayer('l', 'Loser', 'emperor')); if (extra) g.players.push(newPlayer('x', 'Observer', extra)); Object.assign(g, { status: 'playing', phase: 6, turn: 2, storm: 18, active: 'w', order: g.players.map((p) => p.id), deck: baseDeck(), }); for (const p of g.players) { p.forces = p.id === 'x' ? {} : { 'arrakeen:10': 5 }; p.reserves = p.id === 'x' ? 20 : 15; p.spice = 20; p.hand = []; p.traitors = []; for (const leader of p.leaders) leader.strength = 0; } if (extra === 'moritani') { player(g, 'l').ally = 'x'; player(g, 'x').ally = 'l'; } return g; } function hold(g: Game, id: string, kind: Card['kind']) { const index = g.deck.findIndex((c) => c.kind === kind); assert.ok(index >= 0); const [card] = g.deck.splice(index, 1); player(g, id).hand.push(card); return card.id; } function inventory(g: Game) { const ids = [...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)] .map((c) => c.id) .sort(); assert.equal( new Set(ids).size, ids.length, 'every physical card has one custodian', ); for (const p of g.players) assert.equal( p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0), 20, ); return ids; } function prepared(state: Game) { let g = applyAction(state, 'w', { type: 'chooseBattle', territory: 'arrakeen', target: 'l', }); for (let i = 0; i < 40; i++) { if (g.response) g = applyAction( g, g.players.find((p) => !g.response!.passed.includes(p.id))!.id, { type: 'passResponse' }, ); else if (g.battle?.preparation) g = applyAction(g, g.battle.preparation.owner, { type: 'declineBattlePower', }); else return g; } throw Error('Preparation stalled'); } function beforeLast( state: Game, options: { hero?: string; weapon?: string; defense?: string; loserWeapon?: string; loserDefense?: string; dial?: number; support?: number; traitors?: boolean; } = {}, ) { let g = prepared(state); g = applyAction(g, 'w', { type: 'battlePlan', dial: options.dial ?? 2, support: options.support ?? 2, leader: options.hero ?? player(g, 'w').leaders[0].id, weapon: options.weapon, defense: options.defense, }); g = applyAction(g, 'l', { type: 'battlePlan', dial: 0, support: 0, leader: player(g, 'l').leaders[0].id, weapon: options.loserWeapon, defense: options.loserDefense, }); return applyAction(g, 'w', { type: 'traitorCall', call: options.traitors ?? false, }); } const finalAction = (call = false): Action => ({ type: 'traitorCall', call }); function frame(g: Game, call = false) { const result = inner(g, 'l', finalAction(call)); assert.equal( result.pendingTreacheryDiscard?.continuation.kind, 'battleResolved', ); return result; } function mandatory(g: Game) { const c = g.pendingTreacheryDiscard!.continuation; assert.equal(c.kind, 'battleResolved'); if (c.kind !== 'battleResolved') throw Error('Wrong continuation'); return c; } function stable(g: Game) { return g.players.map((p) => ({ id: p.id, spice: p.spice, tanks: p.tanks, leaders: p.leaders, })); } function resumed(g: Game) { const result = normalizeAutomaticGame(reload(g)); assert.equal(result.pendingTreacheryDiscard, null); assert.deepEqual(normalizeAutomaticGame(reload(result)), result); return result; } void test('last real traitor vote stages a mixed-owner public discard after double-traitor losses, with no replay', () => { const initial = fixture(); const w = hold(initial, 'w', 'shield'), l = hold(initial, 'l', 'snooper'); player(initial, 'w').traitors = [player(initial, 'l').leaders[0].id]; player(initial, 'l').traitors = [player(initial, 'w').leaders[0].id]; const ids = inventory(initial); const before = beforeLast(initial, { defense: w, loserDefense: l, traitors: true, }); for (const p of before.players) { const revealed = viewGame(before, p.id).battle!; assert.equal(revealed.revealed, true); assert.deepEqual( revealed.cards.map((c) => c.id), [w, l], ); } const pending = frame(before, true), batch = pending.pendingTreacheryDiscard!.batch; assert.deepEqual( batch.entries.map((e) => [e.card.id, e.discardedBy, e.publicFace]), [ [w, 'w', true], [l, 'l', true], ], ); assert.equal(batch.cause, 'battle:mandatory'); assert.equal(mandatory(pending).winner, null); assert.equal(pending.battle, null); assert.deepEqual(pending.lastBattle, ['w', 'l']); assert.deepEqual( pending.players.map((p) => p.tanks), [5, 5], ); assert.deepEqual( pending.players.map((p) => p.leaders[0].deaths), [1, 1], ); const result = resumed(pending); assert.deepEqual(stable(result), stable(pending)); assert.deepEqual(result, applyAction(before, 'l', finalAction(true))); assert.deepEqual(inventory(result), ids); assert.equal(result.resolvedTreacheryDiscardSequence, 1); }); void test('loser defense precedes winner casualties and the separate mandatory hero discard', () => { const initial = fixture(); const hero = hold(initial, 'w', 'hero'), defense = hold(initial, 'l', 'snooper'); const ids = inventory(initial), before = beforeLast(initial, { hero, loserDefense: defense }); const pending = frame(before), c = mandatory(pending); assert.equal(c.casualties!.options.length, 1); assert.deepEqual( pending.pendingTreacheryDiscard!.batch.entries.map((e) => e.discardedBy), ['l'], ); assert.deepEqual(pending.pendingWinnerDiscards?.cards, [hero]); assert.ok(player(pending, 'w').hand.some((card) => card.id === hero)); assert.equal(player(pending, 'w').tanks, 0); assert.equal(player(pending, 'l').tanks, 5); assert.equal(player(pending, 'w').spice, 18); const result = resumed(pending); assert.equal(result.resolvedTreacheryDiscardSequence, 2); assert.equal(result.pendingWinnerDiscards, null); assert.ok(!player(result, 'w').hand.some((card) => card.id === hero)); assert.equal(player(result, 'w').tanks, 2); assert.equal( result.log.filter((e) => e.automatic?.name === 'Battle casualties').length, 1, ); assert.deepEqual(result, applyAction(before, 'l', finalAction())); assert.deepEqual(inventory(result), ids); }); void test('Fedaykin casualty choices survive reload with their committed effective force allocation', () => { const initial = fixture('fremen'); player(initial, 'w').elites = { reserves: 2, tanks: 0, forces: { 'arrakeen:10': 1 }, revived: 0, }; const defense = hold(initial, 'l', 'snooper'); const pending = frame( beforeLast(initial, { loserDefense: defense, support: 0 }), ); const options = mandatory(pending).casualties!.options; assert.ok(options.some((c) => c.normal === 2 && c.elite === 0)); assert.ok(options.some((c) => c.normal === 0 && c.elite === 1)); const result = resumed(pending); assert.equal(result.decision?.kind, 'battleLosses'); assert.equal(player(result, 'w').tanks, 0); for (let choice = 0; choice < options.length; choice++) { const chosen = applyAction(reload(result), 'w', { type: 'decision', choice, }); assert.equal( player(chosen, 'w').tanks, options[choice].normal + options[choice].elite, ); assert.equal(player(chosen, 'w').elites!.tanks, options[choice].elite); inventory(chosen); } }); void test('leader bounty and CHOAM support income occur once across mandatory and later winner discard frames', () => { const initial = fixture('guild', 'choam'); player(initial, 'l').leaders[0].strength = 3; const weapon = hold(initial, 'w', 'projectile'), defense = hold(initial, 'l', 'snooper'); const pending = frame(beforeLast(initial, { weapon, loserDefense: defense })); assert.equal(player(pending, 'w').spice, 21); assert.equal(player(pending, 'l').leaders[0].deaths, 1); assert.equal(player(pending, 'x').spice, 20); let result = resumed(pending); assert.equal(result.decision?.kind, 'battleCards'); const cleanup = inner(result, 'w', { type: 'decision', discard: [weapon] }); assert.equal(cleanup.pendingTreacheryDiscard?.batch.cause, 'battle:winner'); assert.equal(cleanup.pendingTreacheryDiscard?.sequence, 2); assert.deepEqual( cleanup.pendingTreacheryDiscard?.batch.entries.map((e) => e.card.id), [weapon], ); result = resumed(cleanup); assert.equal(player(result, 'x').spice, 21); assert.equal(player(result, 'l').leaders[0].deaths, 1); assert.equal(player(result, 'w').tanks, 2); assert.equal(result.resolvedTreacheryDiscardSequence, 2); inventory(result); }); void test('Moritani reserves loser cards from the mandatory batch and disposes them only in its later cleanup', () => { const initial = fixture('guild', 'moritani'); const hero = hold(initial, 'w', 'hero'), weapon = hold(initial, 'l', 'projectile'), defense = hold(initial, 'l', 'snooper'); const pending = inner( beforeLast(initial, { hero, loserWeapon: weapon, loserDefense: defense }), 'l', finalAction(), ); assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'winnerMandatoryDiscard'); assert.equal(player(pending, 'w').tanks, 2); assert.deepEqual( pending.pendingTreacheryDiscard!.batch.entries.map((e) => e.card.id), [hero], ); assert.deepEqual(pending.moritaniRetention!.played, [weapon, defense]); const result = resumed(pending); assert.equal(result.decision?.kind, 'moritaniRetention'); const next = inner(result, 'l', { type: 'decision', keep: null }); assert.equal(next.pendingTreacheryDiscard?.batch.cause, 'battle:moritani'); assert.deepEqual( next.pendingTreacheryDiscard?.batch.entries.map((e) => e.card.id), [weapon, defense], ); assert.equal(next.moritaniRetention, null); const settled = resumed(next); assert.equal(settled.resolvedTreacheryDiscardSequence, 2); assert.equal(player(settled, 'l').hand.length, 0); inventory(settled); }); void test('an empty mandatory batch goes directly to cleanup without allocating a discard event', () => { const initial = fixture(), before = beforeLast(initial); const result = inner(before, 'l', finalAction()); assert.equal(result.pendingTreacheryDiscard ?? null, null); assert.equal(result.treacheryDiscardSequence ?? 0, 0); assert.equal(player(result, 'w').tanks, 2); assert.deepEqual( normalizeAutomaticGame(result), applyAction(before, 'l', finalAction()), ); }); void test('winner and Moritani cleanup validate the event, complete played set, retained custody and consumed alliance record', () => { for (const source of ['winner', 'moritani'] as const) { const initial = fixture( 'guild', source === 'moritani' ? 'moritani' : undefined, ); const weapon = hold(initial, 'w', 'projectile'), defense = hold(initial, 'w', 'shield'); const loserDefense = hold(initial, 'l', 'snooper'); let g = applyAction( beforeLast(initial, { weapon, defense, loserDefense }), 'l', finalAction(), ); if (source === 'moritani') g = applyAction(g, 'w', { type: 'decision', discard: [] }); const pending = inner( g, source === 'winner' ? 'w' : 'l', source === 'winner' ? { type: 'decision', discard: [weapon] } : { type: 'decision', keep: null }, ); const continuation = (state: Game) => { const c = state.pendingTreacheryDiscard!.continuation; if (c.kind !== 'battleCleanup') throw Error('Missing cleanup'); return c; }; assert.equal(continuation(pending).source, source); const changes: ((g: Game) => void)[] = [ (g) => { continuation(g).event = 'stale'; }, (g) => { continuation(g).played = []; }, (g) => { continuation(g).played.push(continuation(g).played[0]); }, (g) => { continuation(g).player = 'stranger'; }, (g) => { continuation(g).source = source === 'winner' ? 'moritani' : 'winner'; }, (g) => { g.lastBattleContext!.territory = 'carthag'; }, ]; if (source === 'winner') changes.push( (g) => { g.deck.push( structuredClone(player(g, 'w').hand.find((c) => c.id === defense)!), ); }, (g) => { continuation(g).kept = []; }, ); else changes.push( (g) => { delete continuation(g).retention; }, (g) => { player(g, 'x').ally = null; }, (g) => { continuation(g).retention!.turn--; }, (g) => { continuation(g).retention!.played = []; }, ); for (const change of changes) { const corrupt = reload(pending); change(corrupt); const before = structuredClone(corrupt); assert.throws(() => normalizeAutomaticGame(corrupt)); assert.throws(() => viewGame(corrupt, 'w')); assert.throws(() => applyAction(corrupt, 'w', { type: 'advanceBots' })); assert.deepEqual(corrupt, before); } resumed(pending); inventory(pending); } }); void test('battle frames reject stale identity, private faces, custody corruption and inconsistent frozen casualties atomically', () => { const initial = fixture(), defense = hold(initial, 'l', 'snooper'); const valid = frame(beforeLast(initial, { loserDefense: defense })); const corruptions: ((g: Game) => void)[] = [ (g) => { g.turn++; }, (g) => { g.phase = 7; }, (g) => { g.treacheryDiscardSequence!++; }, (g) => { g.lastBattle = ['l', 'w']; }, (g) => { mandatory(g).combatants = ['w', 'w']; }, (g) => { mandatory(g).territory = 'missing'; }, (g) => { mandatory(g).winner = 'stranger'; }, (g) => { g.pendingTreacheryDiscard!.batch.entries[0].publicFace = false; }, (g) => { g.pendingTreacheryDiscard!.batch.entries[0].discardedBy = 'stranger'; }, (g) => { g.deck.push(structuredClone(g.discard[0])); }, (g) => { g.discard = []; }, (g) => { mandatory(g).cards = [defense]; }, (g) => { mandatory(g).casualties!.forces.normal++; }, (g) => { mandatory(g).casualties!.options[0].normal++; }, (g) => { mandatory(g).casualties!.dial = -1; }, (g) => { g.pendingTreacheryDiscard!.batch.cause = 'battle:winner'; }, (g) => { delete mandatory(g).casualties; }, (g) => { mandatory(g).event = 'stale-battle'; }, (g) => { mandatory(g).result = 'traitor'; }, (g) => { g.lastBattleContext!.winner = 'l'; }, (g) => { g.lastBattleContext!.turn--; }, ]; for (const corrupt of corruptions) { const g = reload(valid); corrupt(g); const before = structuredClone(g); assert.throws(() => normalizeAutomaticGame(g)); assert.throws(() => applyAction(g, 'w', { type: 'advanceBots' })); assert.throws(() => viewGame(g, 'w')); assert.deepEqual(g, before); } }); void test('all profiles wait on the public automatic marker; battle faces are public but unrelated hands remain private', () => { const initial = fixture(), defense = hold(initial, 'l', 'snooper'); const secret = hold(initial, 'w', 'worthless'); const pending = frame(beforeLast(initial, { loserDefense: defense })); for (const difficulty of DIFFICULTIES) for (const p of pending.players) { const view = viewGame(pending, p.id); view.players.find((other) => other.id === p.id)!.bot = difficulty; assert.equal(view.automaticContinuationPending, true); assert.deepEqual(botActions(view), []); assert.ok(!('pendingTreacheryDiscard' in view)); const opponent = view.players.find((other) => other.id !== p.id)!; assert.equal(opponent.hand, undefined); if (p.id === 'l') assert.ok(!JSON.stringify(view).includes(secret)); } const before = structuredClone(pending), automatic = runBots(reload(pending), 0); assert.equal(automatic.pendingTreacheryDiscard, null); assert.deepEqual(pending, before); assert.deepEqual( { ...automatic, botsPending: undefined }, { ...resumed(pending), botsPending: undefined }, ); }); for (const blockElite of [false, true]) for (const blockSupport of [false, true]) void test(`persisted Fremen casualties preserve actual Karama effects: elite ${blockElite ? 'canceled' : 'allowed'}, free support ${blockSupport ? 'canceled' : 'allowed'}`, () => { let g = fixture('fremen', 'choam'); player(g, 'w').elites = { reserves: 2, tanks: 0, forces: { 'arrakeen:10': 1 }, revived: 0, }; const defense = hold(g, 'l', 'snooper'); const karamas = g.deck.filter((c) => c.effect === 'karama'); for (const card of karamas) { g.deck.splice( g.deck.findIndex((c) => c.id === card.id), 1, ); player(g, 'x').hand.push(card); } const ids = inventory(g); g = applyAction(g, 'w', { type: 'chooseBattle', territory: 'arrakeen', target: 'l', }); for (let i = 0; g.response && i < 30; i++) { const cancel = (g.response.kind === 'eliteStrength' && blockElite) || (g.response.kind === 'fremenSupport' && blockSupport); if (cancel) g = applyAction(g, 'x', { type: 'card', card: player(g, 'x').hand.find((c) => c.effect === 'karama')!.id, mode: 'cancel', }); else g = applyAction( g, g.players.find((p) => !g.response!.passed.includes(p.id))!.id, { type: 'passResponse' }, ); } assert.equal(g.response, null); assert.equal( g.discard.filter((c) => c.effect === 'karama').length, Number(blockElite) + Number(blockSupport), ); while (g.battle?.preparation) g = applyAction(g, g.battle.preparation.owner, { type: 'declineBattlePower', }); g = applyAction(g, 'w', { type: 'battlePlan', leader: player(g, 'w').leaders[0].id, dial: 2, support: blockSupport ? 2 : 0, }); g = applyAction(g, 'l', { type: 'battlePlan', leader: player(g, 'l').leaders[0].id, dial: 0, support: 0, defense, }); g = applyAction(g, 'w', finalAction()); const pending = frame(g), commitment = mandatory(pending).casualties!; assert.equal(pending.battle, null); assert.equal(commitment.forces.freeSupport, !blockSupport); assert.equal(commitment.forces.eliteStrength, blockElite ? 1 : 2); assert.equal(commitment.forces.normal, 4); assert.equal(commitment.forces.elite, 1); assert.equal(player(pending, 'w').tanks, 0); const recovered = resumed(pending); assert.deepEqual(recovered, applyAction(g, 'l', finalAction())); if (commitment.options.length === 1) { assert.equal(player(recovered, 'w').tanks, 2); assert.equal(player(recovered, 'w').elites!.tanks, 0); assert.notEqual(recovered.decision?.kind, 'battleLosses'); } else { assert.equal(recovered.decision?.kind, 'battleLosses'); assert.equal(player(recovered, 'w').tanks, 0); if (recovered.decision?.kind !== 'battleLosses') throw Error('Missing casualty decision'); assert.deepEqual(recovered.decision.options, commitment.options); } for (const p of recovered.players) viewGame(recovered, p.id); assert.deepEqual(inventory(recovered), ids); }); for (const winner of ['harkonnen', 'choam'] as const) void test(`${winner} simultaneous post-battle effects survive recovery and reject inconsistent saved obligations`, () => { const g = fixture(winner, winner === 'harkonnen' ? 'choam' : undefined); g.players.push(newPlayer('t', 'Tleilaxu', 'tleilaxu')); g.order.push('t'); if (winner === 'choam') player(g, 'w').leaders.push(createAuditorLeader()); g.techTokens = createTechTokens(); g.techTokens.axlotl.owner = 'l'; g.techTokens.production.owner = 'l'; const defense = hold(g, 'l', 'snooper'); hold(g, 'l', 'worthless'); const ids = inventory(g); const before = beforeLast(g, { loserDefense: defense, ...(winner === 'choam' ? { hero: CHOAM_AUDITOR_ID } : {}), }); const pending = frame(before); assert.ok(pending.pendingFaceDance); assert.ok(pending.pendingTech); if (winner === 'harkonnen') { assert.ok(pending.pendingCapture); assert.ok(pending.pendingChoamBattleIncome); } else assert.ok(pending.pendingAuditor); const result = resumed(pending); assert.deepEqual(result, applyAction(before, 'l', finalAction())); assert.equal(result.decision?.kind, 'techToken'); assert.deepEqual(result.pendingFaceDance, pending.pendingFaceDance); assert.deepEqual(result.pendingCapture, pending.pendingCapture); assert.deepEqual(result.pendingAuditor, pending.pendingAuditor); if (winner === 'harkonnen') { assert.equal(player(result, 'x').spice, 21); assert.equal(result.pendingChoamBattleIncome, null); } for (const p of result.players) viewGame(result, p.id); assert.deepEqual(inventory(result), ids); const mutations: ((g: Game) => void)[] = [ (g) => { g.pendingFaceDance!.winner = 'l'; }, (g) => { g.pendingFaceDance!.territory = 'carthag'; }, (g) => { g.pendingFaceDance!.player = 'l'; }, (g) => { g.pendingTech!.player = 'l'; }, (g) => { g.pendingTech!.loser = 'w'; }, (g) => { g.pendingTech!.choices = []; }, (g) => { g.pendingTech!.choices.push(g.pendingTech!.choices[0]); }, (g) => { g.techTokens!.axlotl.owner = 't'; }, ]; if (winner === 'harkonnen') mutations.push( (g) => { g.pendingCapture!.player = 'l'; }, (g) => { g.pendingCapture!.loser = 'w'; }, (g) => { g.pendingCapture!.territory = 'carthag'; }, (g) => { g.pendingChoamBattleIncome!.amount = 0; }, (g) => { g.pendingChoamBattleIncome!.amount = 0.5; }, (g) => { g.pendingChoamBattleIncome!.owner = 'w'; }, ); else mutations.push( (g) => { g.pendingAuditor!.event = 'stale'; }, (g) => { g.pendingAuditor!.territory = 'carthag'; }, (g) => { g.pendingAuditor!.stage = 'response'; }, (g) => { g.pendingAuditor!.owner = 'l'; }, ); for (const mutate of mutations) { const corrupt = reload(pending); mutate(corrupt); const unchanged = structuredClone(corrupt); assert.throws(() => normalizeAutomaticGame(corrupt)); assert.throws(() => applyAction(corrupt, 'w', { type: 'advanceBots' })); assert.throws(() => viewGame(corrupt, 'w')); assert.deepEqual(corrupt, unchanged); } });