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 { createGame, newPlayer, joinGame, applyAction, RuleError, type Game, type Action, } from '../game/engine'; import { baseDeck, createAuditorLeader } from '../game/cards'; import { newRevivalRules } from '../game/revival'; import { createTechTokens } from '../game/tech-tokens'; import { quoteRevivalResume, RevivalResumeError } from '../game/revival-resume'; const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!; const json = (value: T): T => JSON.parse(JSON.stringify(value)); /** Observe genuine declarations immediately before beginRevival's ordinary * CHOAM/stop dispatch. The production module and public start gates are unchanged. */ const engineURL = new URL('../game/engine.ts', import.meta.url); const source = readFileSync(engineURL, 'utf8'); const entry = 'function beginRevival(g: Game, revival: PendingRevival) {'; assert.equal(source.split(entry).length, 2); const observed: { applyAction: typeof applyAction; RuleError: typeof RuleError; captures: Game[]; finishRevival: (g: Game) => void; offerRevivalStop: (g: Game) => void; } = {} as never; let randomCalls = 0; runInNewContext( ts.transpileModule( 'const captures: Game[] = [];\n' + source.replace( entry, entry + '\ncaptures.push(structuredClone({...g, pendingRevival: revival}));', ) + '\nexport { captures, finishRevival, offerRevivalStop };', { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, }, }, ).outputText, { exports: observed, require: createRequire(engineURL), structuredClone, TextEncoder, JSON, crypto: { ...webcrypto, randomUUID: () => { randomCalls++; return webcrypto.randomUUID(); }, }, Math: Object.assign(Object.create(Math), { random: () => { randomCalls++; return 0.25; }, }), }, ); function fixture(advanced = true) { const g = createGame( 'REVIVALRESUME', newPlayer('c', 'CHOAM', 'choam'), advanced, ['choam', 'ix'], ); for (const [id, faction] of [ ['e', 'emperor'], ['t', 'tleilaxu'], ['a', 'atreides'], ['b', 'beneGesserit'], ['i', 'ixians'], ] as const) joinGame(g, newPlayer(id, faction, faction)); Object.assign(g, { status: 'playing', phase: 4, turn: 2, order: g.players.map((p) => p.id), deck: baseDeck(), revivalRules: newRevivalRules(), }); for (const p of g.players) Object.assign(p, { hand: [], spice: 40, tanks: 10, reserves: 10, forces: {}, }); for (const [id, kind] of [ ['e', 'karama'], ['b', 'worthless'], ] as const) { const index = g.deck.findIndex((card) => kind === 'karama' ? card.effect === kind : card.kind === kind, ); player(g, id).hand.push(g.deck.splice(index, 1)[0]); } return g; } const act = (g: Game, id: string, action: Action) => observed.applyAction(g, id, action); function allow(g: Game) { for (let n = 0; n < 40 && (g.decision || g.response); n++) { if (g.decision) { assert.ok(['choamFreeRevival', 'revivalStop'].includes(g.decision.kind)); g = act(g, g.decision.player, { type: 'decision', decline: true }); } else { const id = g.players.find((p) => !g.response!.passed.includes(p.id))!.id; g = act(g, id, { type: 'passResponse' }); } } assert.equal(g.pendingRevival, null); return g; } type Case = | 'forces' | 'limitDiscount' | 'choam' | 'ix' | 'early' | 'foreign' | 'native' | 'kwisatz' | 'negotiated' | 'buyback' | 'auditor' | 'extra'; function declaration(kind: Case, advanced = true) { let g = fixture(advanced); observed.captures.length = 0; if (kind === 'limitDiscount' || kind === 'ix') { const id = kind === 'ix' ? 'i' : 'a'; player(g, 't').ally = id; player(g, id).ally = 't'; g = act(g, 't', { type: 'tleilaxuAllyDiscount' }); if (kind === 'limitDiscount') g = act(g, 't', { type: 'tleilaxuRevivalLimit', target: id }); else player(g, id).elites = { tanks: 2, reserves: 2, forces: {}, revived: 0 }; g = act(g, id, { type: 'revive', amount: kind === 'ix' ? 2 : 5, ...(kind === 'ix' ? { elite: 1, freeElite: 0 } : {}), }); } else if (kind === 'forces' || kind === 'choam') { g = act(g, kind === 'choam' ? 'c' : 'e', { type: 'revive', amount: 3 }); } else if (kind === 'extra') { player(g, 'e').ally = 'a'; player(g, 'a').ally = 'e'; g = act(g, 'e', { type: 'emperorRevival', amount: 2 }); while (!observed.captures.length) { assert.equal(g.response?.kind, 'emperorRevival'); g = act( g, g.players.find((p) => !g.response!.passed.includes(p.id))!.id, { type: 'passResponse' }, ); } } else if (kind === 'early' || kind === 'foreign') { player(g, 't').leaders[0].dead = true; player(g, 't').leaders[0].deaths = 1; player(g, 'a').leaders[0].dead = true; player(g, 'a').leaders[0].deaths = 1; g = act(g, 't', { type: kind === 'early' ? 'reviveLeader' : 'reviveForeignGhola', leader: player(g, kind === 'early' ? 't' : 'a').leaders[0].id, }); } else if (kind === 'negotiated' || kind === 'buyback') { const leader = player(g, 'a').leaders[0]; leader.dead = true; leader.deaths = 1; if (kind === 'buyback') leader.gholaBy = 't'; g = act(g, 'a', { type: 'requestLeaderRevival', leader: leader.id }); g = act(g, 't', { type: 'quoteLeaderRevival', target: 'a', amount: 17 }); g = act(g, 'a', { type: 'acceptLeaderRevival' }); } else if (kind === 'auditor') { const leader = createAuditorLeader(); leader.dead = true; leader.deaths = 1; player(g, 'c').leaders.push(leader); g = act(g, 'c', { type: 'reviveLeader', leader: leader.id }); } else { player(g, 'a').leaders.forEach((l) => { l.dead = true; l.deaths = 1; }); if (kind === 'kwisatz') player(g, 'a').kwisatz = { dead: true, revivalCycle: 1 }; g = act(g, 'a', { type: kind === 'kwisatz' ? 'reviveKwisatz' : 'reviveLeader', ...(kind === 'native' ? { leader: player(g, 'a').leaders[0].id } : {}), }); } assert.equal(observed.captures.length, 1); return { g, captured: json(observed.captures[0]) }; } function finishState(captured: Game) { const state = json(captured); state.decision = null; state.response = null; return state; } void test('unchanged mixed free/paid revival reaches the real stop decision before any queued or paid work', () => { const { g, captured } = declaration('forces'); const before = json(captured), rng = randomCalls; const quote = quoteRevivalResume(captured, 'stop'); assert.equal(quote.outcome, 'decision'); assert.deepEqual(quote.nextDecision, { kind: 'revivalStop', player: 't', recipient: 'e', revival: 'forces', }); assert.deepEqual(quote.pending, { player: 'e', kind: 'forces', amount: 3, elite: 0, free: 1, cost: 4, normalCost: 4, checks: [], }); assert.equal(quote.nextResponse, undefined); assert.equal(randomCalls, rng); assert.deepEqual(captured, before); quote.pending.checks.push('revivalDiscount'); assert.deepEqual(captured, before); const done = allow(g); assert.equal(player(done, 'e').tanks, 7); assert.equal(player(done, 'e').reserves, 13); assert.equal(player(done, 'e').spice, 36); assert.equal(player(done, 't').spice, 45); assert.equal(done.revivalFreeIncome?.e, 2); }); void test('initial ordered limit then discount queue remains frozen, including its agreed half price', () => { const { g, captured } = declaration('limitDiscount'); assert.deepEqual(captured.pendingRevival?.checks, [ 'revivalLimit', 'revivalDiscount', ]); assert.equal(quoteRevivalResume(captured, 'stop').outcome, 'decision'); const quote = quoteRevivalResume(captured, 'finish'); assert.equal(quote.outcome, 'response'); assert.deepEqual(quote.nextResponse, { kind: 'revivalLimit', owner: 't', recipient: 'a', passed: [], }); assert.deepEqual(quote.pending, captured.pendingRevival); const after = allow(g); assert.equal(player(after, 'a').revived, 5); assert.equal(player(after, 'a').spice, 40 - captured.pendingRevival!.cost); }); void test('Basic skips the Advanced stop while finish explicitly bypasses it without changing a hand-dependent offer', () => { const { captured } = declaration('forces', false); const basic = quoteRevivalResume(captured, 'stop'); assert.equal(basic.outcome, 'revive'); captured.advanced = true; const choice = quoteRevivalResume(captured, 'stop'); assert.equal(choice.outcome, 'decision'); for (const p of captured.players) p.hand = []; assert.deepEqual(quoteRevivalResume(captured, 'stop'), choice); assert.equal(quoteRevivalResume(captured, 'finish').outcome, 'revive'); player(captured, 't').specialKaramaUsed = true; assert.equal(quoteRevivalResume(captured, 'stop').outcome, 'revive'); }); void test('all real leader, foreign, negotiated, Auditor, and Emperor-extra producers keep their source and frozen price', () => { for (const kind of [ 'early', 'foreign', 'native', 'kwisatz', 'negotiated', 'buyback', 'auditor', 'extra', ] as const) { const { g, captured } = declaration(kind); const before = json(captured), quote = quoteRevivalResume(captured, 'finish'); assert.deepEqual(quote.pending, captured.pendingRevival, kind); assert.deepEqual(captured, before, kind); const after = allow(g), pending = captured.pendingRevival!; if (kind === 'extra') { assert.equal(after.emperorExtra.a, 2); assert.equal(player(after, 'a').revived, 0); assert.equal(player(after, 'e').spice, 36); assert.equal(player(after, 't').spice, 44); assert.equal(quote.nextResponse?.recipient, 'e'); } else if (kind === 'kwisatz') { assert.equal(player(after, 'a').kwisatz?.dead, false); assert.equal(player(after, 'a').kwisatz?.revivalCycle, 2); } else { const leader = after.players .flatMap((p) => p.leaders) .find((l) => l.id === pending.leader)!; assert.equal(leader.dead, false, kind); assert.equal(leader.gholaBy, kind === 'foreign' ? 't' : undefined, kind); } } }); void test('Ix free cyborg allocation remains the actual selected frozen quote', () => { const { g, captured } = declaration('ix'); const pending = captured.pendingRevival!; assert.equal(pending.free, 1); assert.equal(pending.normalCost, 3); assert.equal(pending.cost, 2); assert.deepEqual(quoteRevivalResume(captured, 'finish').pending, pending); const after = allow(g); assert.equal(player(after, 'i').elites?.tanks, 1); assert.equal(player(after, 'i').elites?.reserves, 3); assert.equal(player(after, 'i').spice, 38); }); void test('unfunded and Fremen allowance abandonment precede physical custody and never produce income', () => { const { captured } = declaration('forces'); player(captured, 'e').spice = 0; player(captured, 'e').tanks = 0; const q = quoteRevivalResume(captured, 'finish'); assert.equal(q.outcome, 'unfunded'); assert.equal(q.nextResponse, undefined); const real = finishState(captured); observed.finishRevival(real); assert.equal(real.pendingRevival, null); assert.equal(player(real, 'e').tanks, 0); const fremen = json(captured); player(fremen, 'e').faction = 'fremen'; fremen.players = fremen.players.filter((p) => p.id !== 't'); player(fremen, 'e').tanks = 10; player(fremen, 'e').revived = 3; assert.equal(quoteRevivalResume(fremen, 'finish').outcome, 'fremenLimit'); const actual = finishState(fremen); observed.finishRevival(actual); assert.equal(actual.pendingRevival, null); assert.equal(player(actual, 'e').tanks, 10); }); void test('safe exact Axlotl income and once-per-turn free-income receipts are quoted only at actual commit', () => { const { captured } = declaration('forces'); captured.techTokens = createTechTokens(captured.players); captured.techTokens.heighliners.owner = 't'; const q = quoteRevivalResume(captured, 'finish'); assert.deepEqual(q.techIncome, { id: 'axlotl', owner: 't', spice: 2, triggeredTurn: 2, }); assert.deepEqual(q.freeIncome, { player: 'e', turn: 2 }); assert.deepEqual(q.nextResponse, { kind: 'revivalIncome', owner: 't', recipient: 'e', amount: 5, passed: [], }); const real = finishState(captured); observed.finishRevival(real); assert.equal(real.techTokens!.axlotl.spice, 2); assert.equal(real.techTokens!.axlotl.triggeredTurn, 2); assert.deepEqual(json(real.response), q.nextResponse); assert.equal(quoteRevivalResume(captured, 'stop').techIncome, undefined); captured.revivalFreeIncome = { e: 2 }; captured.techTokens.axlotl.triggeredTurn = 2; const repeated = quoteRevivalResume(captured, 'finish'); assert.equal(repeated.techIncome, undefined); assert.equal(repeated.nextResponse?.amount, 4); }); void test('corrupted source, ordered checks, physical custody and counter overflow reject without mutation', () => { const { captured } = declaration('forces'); const cases: [string, (g: Game) => void][] = [ [ 'phase', (g) => { g.phase = 5; }, ], [ 'turn', (g) => { g.turn = NaN; }, ], [ 'missing request', (g) => { g.pendingRevival = null; }, ], [ 'target', (g) => { g.pendingRevival!.player = 'absent'; }, ], [ 'checks', (g) => { g.pendingRevival!.checks = null as never; }, ], [ 'reversed checks', (g) => { g.pendingRevival!.checks = ['revivalDiscount', 'revivalLimit']; }, ], [ 'duplicate checks', (g) => { g.pendingRevival!.checks = ['revivalDiscount', 'revivalDiscount']; }, ], [ 'wrong check kind', (g) => { g.pendingRevival!.checks = ['foreignGhola']; }, ], [ 'missing check owner', (g) => { g.players = g.players.filter((p) => p.id !== 't'); g.pendingRevival!.checks = ['revivalDiscount']; }, ], [ 'amount', (g) => { g.pendingRevival!.amount = 99; }, ], [ 'within-tanks amount changed without repricing', (g) => { g.pendingRevival!.amount = 4; }, ], [ 'negative cost', (g) => { g.pendingRevival!.cost = -1; }, ], [ 'unsupported force rate', (g) => { g.pendingRevival!.cost = 0; }, ], [ 'wrong payer', (g) => { g.pendingRevival!.payer = 'c'; }, ], [ 'missing typed tanks', (g) => { g.pendingRevival!.elite = 1; }, ], [ 'elite custody', (g) => { player(g, 'e').elites = { tanks: 9, reserves: 0, forces: {}, revived: 0, }; }, ], [ 'reserve overflow', (g) => { player(g, 'e').reserves = Number.MAX_SAFE_INTEGER; }, ], [ 'quota overflow', (g) => { player(g, 'e').revived = Number.MAX_SAFE_INTEGER; }, ], [ 'free counter', (g) => { player(g, 'e').freeForcesRevived = -1; }, ], ]; for (const [label, mutate] of cases) { const bad = json(captured); mutate(bad); const before = structuredClone(bad); assert.throws( () => quoteRevivalResume(bad, 'finish'), RevivalResumeError, label, ); assert.deepEqual(bad, before, label); } }); void test('Axlotl and immediate income corruption rejects but genuine earlier decision remains a boundary', () => { const { captured } = declaration('forces'); captured.techTokens = createTechTokens(captured.players); for (const mutate of [ (g: Game) => { g.techTokens!.axlotl.owner = 'absent'; }, (g: Game) => { g.techTokens!.heighliners = null as never; }, (g: Game) => { g.techTokens!.axlotl.spice = -1; }, (g: Game) => { g.revivalFreeIncome = { e: -1 }; }, ]) { const bad = json(captured); mutate(bad); const before = structuredClone(bad); assert.equal(quoteRevivalResume(bad, 'stop').outcome, 'decision'); assert.throws(() => quoteRevivalResume(bad, 'finish'), RevivalResumeError); assert.deepEqual(bad, before); } }); void test('legacy unknown free usage remains unknown; impossible leader/Emperor custody rejects', () => { const { captured } = declaration('forces'); player(captured, 'e').revived = 1; delete player(captured, 'e').freeForcesRevived; assert.equal(quoteRevivalResume(captured, 'finish').outcome, 'revive'); const state = finishState(captured); observed.finishRevival(state); assert.equal(player(state, 'e').freeForcesRevived, undefined); for (const kind of ['native', 'kwisatz', 'foreign', 'extra'] as const) { const { captured: bad } = declaration(kind); if (kind === 'native') player(bad, 'a').leaders[0].capturedBy = 'e'; if (kind === 'kwisatz') player(bad, 'a').kwisatz!.dead = false; if (kind === 'foreign') player(bad, 't').leaders[0].dead = false; if (kind === 'extra') bad.emperorExtra.a = Number.MAX_SAFE_INTEGER; assert.throws( () => quoteRevivalResume(bad, 'finish'), RevivalResumeError, kind, ); } }); void test('actual printed and BG cancellation of reactive La La La reject corrupted unchanged revival before cost', () => { for (const form of ['printed', 'converted'] as const) { let g = fixture(); const i = g.deck.findIndex((c) => c.name === 'La La La'); assert.ok(i >= 0); const card = g.deck.splice(i, 1)[0]; player(g, 'c').hand.push(card); g = act(g, 'e', { type: 'revive', amount: 3 }); assert.equal(g.decision?.kind, 'choamFreeRevival'); g = act(g, 'c', { type: 'card', card: card.id, mode: 'choam' }); assert.equal(g.response?.kind, 'choamWorthless'); const actor = form === 'printed' ? 'e' : 'b'; const cost = player(g, actor).hand.find((c) => form === 'printed' ? c.effect === 'karama' : c.kind === 'worthless', )!; for (const mutate of [ (bad: Game) => { bad.pendingRevival!.cost = 0; }, (bad: Game) => { bad.pendingRevival!.amount = 4; }, (bad: Game) => { bad.pendingRevival!.checks = null as never; }, (bad: Game) => { player(bad, 'e').elites = { tanks: 9, reserves: 0, forces: {}, revived: 0, }; }, ]) { const bad = json(g); mutate(bad); const before = structuredClone(bad), rng = randomCalls; assert.throws( () => act(bad, actor, { type: 'card', mode: 'cancel', card: cost.id }), observed.RuleError, ); assert.deepEqual(bad, before); assert.equal(randomCalls, rng); } const paid = act(g, actor, { type: 'card', mode: 'cancel', card: cost.id }); const done = allow(paid); assert.equal(done.discard.filter((c) => c.id === cost.id).length, 1); assert.equal(player(done, 'e').revived, 3); assert.equal(player(done, 'e').spice, 36); assert.equal(player(done, 't').spice, 45); } });