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 } from '../game/cards'; import type { FactionId } from '../game/catalog'; import { createTerrorState, placeTerror, type TerrorKind, } from '../game/moritani-terror'; import { createRicheseNoField } from '../game/richese-no-field'; 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( kind: TerrorKind = 'sabotage', entrant: FactionId = 'emperor', ) { const g = createGame('TERRORFRAME', newPlayer('m', 'Moritani', 'moritani')); g.players.push( newPlayer('e', 'Entrant', entrant), newPlayer('a', 'Atreides', 'atreides'), ); Object.assign(g, { status: 'playing', phase: 5, turn: 2, storm: 18, active: 'e', order: ['e', 'm', 'a'], movementRemaining: ['e', 'm', 'a'], deck: baseDeck(), }); for (const p of g.players) { p.forces = {}; p.reserves = 20; p.spice = 20; p.hand = []; } g.moritaniTerror = createTerrorState(() => 0); const token = g.moritaniTerror.tokens.find((t) => t.kind === kind)!; g.moritaniTerror = placeTerror(g.moritaniTerror, token.id, 'arrakeen', 1); return g; } function hold(g: Game, id: string, count: number) { player(g, id).hand.push(...g.deck.splice(0, count)); } function named(g: Game, id: string, name: string) { const index = g.deck.findIndex((c) => c.name === name); assert.ok(index >= 0, name); const [card] = g.deck.splice(index, 1); player(g, id).hand.push(card); return card.id; } const enter = (g: Game) => applyAction(g, 'e', { type: 'ship', territory: 'arrakeen', sector: 10, amount: 2, }); const decide = (g: Game, a: Omit) => applyAction(g, 'm', { type: 'decision', ...a }); function inventory(g: Game) { const cards = [...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)] .map((c) => c.id) .sort(); assert.equal(new Set(cards).size, cards.length); for (const p of g.players) assert.equal( p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0), 20, ); return cards; } function continuation(g: Game) { const c = g.pendingTreacheryDiscard!.continuation; if (c.kind !== 'terrorDiscard') throw Error('Missing Terror discard continuation'); return c; } function recover(g: Game) { const old = structuredClone(g), done = normalizeAutomaticGame(reload(g)); assert.deepEqual(g, old); assert.equal(done.pendingTreacheryDiscard, null); assert.deepEqual(normalizeAutomaticGame(reload(done)), done); return done; } function sabotage(gift = false) { const g = fixture(); hold(g, 'e', 3); if (gift) hold(g, 'm', 1); const before = enter(g), pending = inner(before, 'm', { type: 'decision', reveal: true }); assert.equal(continuation(pending).source, 'sabotage'); return { before, pending }; } function robbery(initialCards = 4, emptyDeck = false) { const initial = fixture('robbery'); hold(initial, 'm', initialCards); if (emptyDeck) { initial.discard.push(...initial.deck); initial.deck = []; } const before = decide(decide(enter(initial), { reveal: true }), { choice: 'card', }); assert.equal(before.pendingTerrorEntry?.stage, 'discard'); return before; } for (const gift of [false, true]) void test(`Sabotage recovery preserves its one random victim and ${gift ? 'opens the optional gift' : 'finishes without a gift'}`, () => { const { before, pending } = sabotage(gift), batch = pending.pendingTreacheryDiscard!.batch; const c = continuation(pending), victim = batch.entries[0].card; assert.equal(batch.entries.length, 1); assert.equal(batch.cause, 'terror:sabotage'); assert.equal(batch.entries[0].discardedBy, 'e'); assert.equal(batch.entries[0].publicFace, false); assert.equal(c.entry.stage, 'offer'); assert.equal(c.discardedHandSize, 3); assert.equal(pending.pendingTerrorEntry, null); assert.equal(pending.decision, null); assert.ok(player(before, 'e').hand.some((card) => card.id === victim.id)); assert.ok(!player(pending, 'e').hand.some((card) => card.id === victim.id)); assert.equal(player(pending, 'e').spice, 18); assert.equal(player(pending, 'e').reserves, 18); const token = pending.moritaniTerror!.tokens.find( (t) => t.id === c.entry.token, )!; assert.equal(token.status, 'removed'); assert.equal(token.location, null); const done = recover(pending); assert.deepEqual(done.discard, pending.discard); assert.deepEqual(done.deck, pending.deck); assert.deepEqual(done.players, pending.players); assert.deepEqual(done.moritaniTerror, pending.moritaniTerror); assert.deepEqual(done.log, pending.log); assert.equal(done.resolvedTreacheryDiscardSequence, 1); if (gift) { assert.equal(done.pendingTerrorEntry?.stage, 'gift'); assert.equal(done.decision?.kind, 'moritaniTerror'); const gifted = player(done, 'm').hand[0].id; const result = decide(reload(done), { card: gifted }); assert.ok(player(result, 'e').hand.some((card) => card.id === gifted)); assert.equal(result.pendingTerrorEntry, null); assert.equal(result.treacheryDiscardSequence, 1); assert.deepEqual(result.discard, done.discard); } else assert.equal(done.pendingTerrorEntry, null); assert.deepEqual(inventory(done), inventory(before)); }); void test('empty Sabotage does not invent a discard event, while still permitting the optional gift', () => { for (const gift of [false, true]) { const initial = fixture(); if (gift) hold(initial, 'm', 1); const result = inner(enter(initial), 'm', { type: 'decision', reveal: true, }); assert.equal(result.pendingTreacheryDiscard ?? null, null); assert.equal(result.treacheryDiscardSequence ?? 0, 0); assert.equal(result.discard.length, 0); assert.equal( result.pendingTerrorEntry?.stage ?? null, gift ? 'gift' : null, ); } }); void test('Robbery recovery never redraws or reshuffles when discarding an old or newly drawn card', () => { for (const emptyDeck of [false, true]) for (const drawn of [false, true]) { const before = robbery(4, emptyDeck), hand = player(before, 'm').hand; const card = hand[drawn ? 4 : 0].id, pending = inner(before, 'm', { type: 'decision', card }); assert.equal(continuation(pending).source, 'robberyOverflow'); assert.equal(continuation(pending).entry.stage, 'discard'); assert.equal(continuation(pending).discardedHandSize, 5); assert.equal( pending.pendingTreacheryDiscard!.batch.entries[0].discardedBy, 'm', ); assert.equal( pending.pendingTreacheryDiscard!.batch.entries[0].publicFace, false, ); const done = recover(pending); assert.deepEqual(done.deck, pending.deck); assert.deepEqual(done.discard, pending.discard); assert.deepEqual(done.players, pending.players); assert.deepEqual(done.log, pending.log); assert.equal(player(done, 'm').hand.length, 4); assert.equal(done.pendingTerrorEntry, null); assert.deepEqual(done, decide(before, { card })); assert.deepEqual(inventory(done), inventory(before)); } }); void test('exceptional Robbery overflow reopens only the next discard and retires each batch once', () => { const before = robbery(5), one = inner(before, 'm', { type: 'decision', card: player(before, 'm').hand[0].id, }); const next = recover(one); assert.equal(player(next, 'm').hand.length, 5); assert.equal(next.pendingTerrorEntry?.stage, 'discard'); const two = inner(next, 'm', { type: 'decision', card: player(next, 'm').hand[0].id, }); assert.equal(two.pendingTreacheryDiscard!.sequence, 2); assert.notEqual( two.pendingTreacheryDiscard!.batch.event, one.pendingTreacheryDiscard!.batch.event, ); const done = recover(two); assert.equal(player(done, 'm').hand.length, 4); assert.equal(done.pendingTerrorEntry, null); assert.equal(done.resolvedTreacheryDiscardSequence, 2); assert.deepEqual(done.deck, before.deck); const replay = reload(done); replay.pendingTreacheryDiscard = one.pendingTreacheryDiscard; assert.throws(() => normalizeAutomaticGame(replay)); }); void test('all seats see only the automatic marker and hidden-face changes preserve their projections', () => { const rob = robbery(), frames = [ sabotage(true).pending, inner(rob, 'm', { type: 'decision', card: player(rob, 'm').hand[0].id }), ]; for (const pending of frames) { const alternate = reload(pending), entry = alternate.pendingTreacheryDiscard!.batch.entries[0]; const index = alternate.discard.findIndex((c) => c.id === entry.card.id), replacement = alternate.deck[0]; alternate.deck[0] = alternate.discard[index]; alternate.discard[index] = replacement; entry.card = structuredClone(replacement); for (const p of pending.players) { const view = viewGame(pending, p.id); assert.equal(view.automaticContinuationPending, true); assert.equal(view.terrorEntry, null); assert.ok(!('pendingTreacheryDiscard' in view)); assert.ok(!('discard' in view)); assert.ok( !JSON.stringify(view).includes( pending.pendingTreacheryDiscard!.batch.entries[0].card.id, ), ); assert.deepEqual(viewGame(alternate, p.id), view); for (const level of DIFFICULTIES) { view.players.find((x) => x.id === p.id)!.bot = level; assert.deepEqual(botActions(view), []); } } const done = runBots(reload(pending), 0); assert.equal(done.pendingTreacheryDiscard, null); assert.deepEqual( { ...done, botsPending: undefined }, { ...recover(pending), botsPending: undefined }, ); } }); void test('Terror frames reject stale stage, removed-token identity, hand counts, overlays and physical custody atomically', () => { const before = robbery(), frames = [ sabotage().pending, inner(before, 'm', { type: 'decision', card: player(before, 'm').hand[0].id, }), ]; for (const valid of frames) { const token = (g: Game) => g.moritaniTerror!.tokens.find( (t) => t.id === continuation(g).entry.token, )!; const mutations: ((g: Game) => void)[] = [ (g) => { g.turn++; }, (g) => { g.phase = 4; }, (g) => { g.treacheryDiscardSequence!++; }, (g) => { g.pendingTreacheryDiscard!.batch.event = 'stale'; }, (g) => { continuation(g).owner = 'e'; }, (g) => { continuation(g).entry.entrant = 'm'; }, (g) => { continuation(g).entry.entrant = 'missing'; }, (g) => { continuation(g).entry.stage = 'gift'; }, (g) => { continuation(g).entry.turn--; }, (g) => { continuation(g).entry.phase--; }, (g) => { continuation(g).entry.territory = 'red_chasm'; }, (g) => { continuation(g).entry.sector = 1; }, (g) => { continuation(g).entry.amount = -1; }, (g) => { continuation(g).entry.elite = 3; }, (g) => { continuation(g).entry.resume = 'wormRide'; }, (g) => { continuation(g).discardedHandSize++; }, (g) => { continuation(g).discardedHandSize = 0; }, (g) => { token(g).status = 'placed'; }, (g) => { token(g).location = 'arrakeen'; }, (g) => { token(g).kind = 'assassination'; }, (g) => { g.moritaniTerror!.tokens.push(structuredClone(token(g))); }, (g) => { continuation(g).entry.token = 'missing'; }, (g) => { g.pendingTerrorEntry = structuredClone(continuation(g).entry); }, (g) => { g.pendingTreacheryDiscard!.batch.entries[0].publicFace = true; }, (g) => { g.pendingTreacheryDiscard!.batch.entries[0].discardedBy = 'a'; }, (g) => { g.pendingTreacheryDiscard!.batch.cause = 'terror:wrong'; }, (g) => { g.deck.push( structuredClone(g.pendingTreacheryDiscard!.batch.entries[0].card), ); }, (g) => { g.discard = g.discard.filter( (c) => c.id !== g.pendingTreacheryDiscard!.batch.entries[0].card.id, ); }, ]; for (const key of [ 'truthtrance', 'response', 'decision', 'pendingNullentropy', 'pendingRicheseGift', 'pendingKarama', ]) mutations.push((g) => { Object.assign(g, { [key]: {} }); }); for (const mutate of mutations) { const corrupt = reload(valid); mutate(corrupt); const old = structuredClone(corrupt); assert.throws(() => normalizeAutomaticGame(corrupt)); assert.throws(() => applyAction(corrupt, 'm', { type: 'advanceBots' })); assert.throws(() => viewGame(corrupt, 'm')); assert.deepEqual(corrupt, old); } } }); function wormEntry(remaining: boolean, truth = false) { const g = fixture('robbery', 'fremen'); Object.assign(g, { advanced: true, phase: 1, nexus: true, active: null, order: ['e', 'm', 'a'], }); player(g, 'e').forces = { 'imperial_basin:10': 3, ...(remaining ? { 'hagga_basin:11': 1 } : {}), }; player(g, 'e').reserves = remaining ? 16 : 17; const karama = named(g, 'e', 'Karama'); if (truth) named(g, 'm', 'Truthtrance'); hold(g, 'm', truth ? 3 : 4); g.decision = { kind: 'wormRide', player: 'e', territory: 'imperial_basin' }; g.wormRides = remaining ? ['hagga_basin'] : []; const entered = applyAction(g, 'e', { type: 'decision', accept: true, territory: 'arrakeen', sector: 10, forces: { 'imperial_basin:10': 2 }, }); return applyAction(entered, 'e', { type: 'card', mode: 'special', card: karama, territory: 'broken_land', }); } for (const remaining of [false, true]) void test(`Robbery discard restores the summoned Nexus before ${remaining ? 'the remaining worm ride' : 'phase advancement'}`, () => { let g = wormEntry(remaining); const forces = structuredClone(player(g, 'e').forces); assert.equal(g.summonedNexusBeforeRides, true); g = decide(decide(g, { reveal: true }), { choice: 'card' }); const pending = inner(g, 'm', { type: 'decision', card: player(g, 'm').hand[0].id, }); assert.equal(continuation(pending).entry.resume, 'wormRide'); const after = recover(pending); assert.equal(after.nexus, true); assert.equal(after.phase, 1); assert.equal(after.summonedNexusBeforeRides, false); assert.equal(after.pendingTerrorEntry, null); assert.equal(after.decision, null); assert.deepEqual(after.ready, []); assert.deepEqual(player(after, 'e').forces, forces); assert.deepEqual(after.deck, g.deck); let result = after; for (const p of result.players) result = applyAction(result, p.id, { type: 'ready' }); if (remaining) assert.deepEqual(result.decision, { kind: 'wormRide', player: 'e', territory: 'hagga_basin', }); else assert.equal(result.phase, 2); assert.deepEqual(player(result, 'e').forces, forces); inventory(result); }); void test('Truthtrance consumes Robbery overflow with only its own discard event and resumes the deferred Nexus once', () => { let g = wormEntry(true, true); const truth = player(g, 'm').hand.find((c) => c.effect === 'truthtrance')!.id; g = decide(decide(g, { reveal: true }), { choice: 'card' }); const deck = structuredClone(g.deck), forces = structuredClone(player(g, 'e').forces); g = applyAction(g, 'm', { type: 'card', card: truth }); while (g.truthtrance?.stage === 'priority') { const id = g.players.find((p) => !g.truthtrance!.passed.includes(p.id))!.id; g = applyAction(g, id, { type: 'truthPass' }); } g = applyAction(g, 'm', { type: 'truthAsk', question: { kind: 'fact', target: 'a', fact: { kind: 'hand', name: 'Shield' }, }, }); g = applyAction(reload(g), 'a', { type: 'truthAnswer', answer: 'no' }); assert.equal(g.pendingTerrorEntry, null); assert.equal(g.decision, null); assert.equal(g.truthtrance, null); assert.equal(g.treacheryDiscardSequence, 1); assert.equal(g.resolvedTreacheryDiscardSequence, 1); assert.equal(g.pendingTreacheryDiscard ?? null, null); assert.equal(player(g, 'm').hand.length, 4); assert.equal(g.nexus, true); assert.equal(g.phase, 1); assert.deepEqual(g.deck, deck); assert.deepEqual(player(g, 'e').forces, forces); assert.equal(g.discard.filter((c) => c.id === truth).length, 1); assert.deepEqual(normalizeAutomaticGame(reload(g)), reload(g)); inventory(g); }); void test('saved Terror origins require the real Fremen worm-ride continuation and reject conflicting active contexts', () => { let worm = wormEntry(true); worm = decide(decide(worm, { reveal: true }), { choice: 'card' }); const ridden = inner(worm, 'm', { type: 'decision', card: player(worm, 'm').hand[0].id, }); const shipped = sabotage().pending; assert.equal(continuation(ridden).entry.cause, 'wormRide'); assert.equal(continuation(ridden).entry.resume, 'wormRide'); assert.equal(continuation(shipped).entry.cause, 'shipment'); assert.equal(continuation(shipped).entry.resume, 'none'); const cases: [Game, (g: Game) => void][] = [ [ ridden, (g) => { continuation(g).entry.resume = 'none'; }, ], [ ridden, (g) => { player(g, 'e').faction = 'emperor'; }, ], [ ridden, (g) => { continuation(g).entry.cause = 'movement'; }, ], [ ridden, (g) => { // Keep the generic frame stamp coherent so the producer's phase binding // itself rejects a worm ride transplanted into Shipment & Movement. g.phase = 5; continuation(g).entry.phase = 5; g.pendingTreacheryDiscard!.batch.phase = 5; g.pendingTreacheryDiscard!.batch.event = `discard:${g.turn}:5:${g.treacheryDiscardSequence}`; }, ], [ shipped, (g) => { continuation(g).entry.cause = 'wormRide'; }, ], [ shipped, (g) => { continuation(g).entry.resume = 'wormRide'; }, ], ]; for (const source of [ridden, shipped]) for (const key of ['pendingExchange', 'summonedWorm']) cases.push([ source, (g) => { Object.assign(g, { [key]: {} }); }, ]); for (const [source, mutate] of cases) { const corrupt = reload(source); mutate(corrupt); const before = structuredClone(corrupt); assert.throws(() => normalizeAutomaticGame(corrupt)); assert.throws(() => applyAction(corrupt, 'm', { type: 'advanceBots' })); assert.throws(() => viewGame(corrupt, 'm')); assert.deepEqual(corrupt, before); } recover(ridden); recover(shipped); }); void test('an actual zero-valued No-Field shipment triggers Sabotage and restores its one-marker arrival receipt', () => { const initial = fixture('sabotage', 'richese'); initial.expansions = ['choam']; const richese = player(initial, 'e'); richese.noField = createRicheseNoField([ 'zero-marker', 'three-marker', 'five-marker', ]); richese.noFieldEvent = 'zero-shipment-event'; hold(initial, 'e', 2); const ids = inventory(initial); const entered = applyAction(initial, 'e', { type: 'ship', noField: 'zero-marker', event: richese.noFieldEvent, territory: 'arrakeen', sector: 10, }); assert.equal(entered.pendingTerrorEntry?.stage, 'offer'); // The concealed zero marker counts as one arrival, without moving any // physical reserve force. The Terror receipt records that public presence. assert.equal(entered.pendingTerrorEntry?.amount, 1); assert.equal(entered.pendingTerrorEntry?.elite, 0); assert.equal(player(entered, 'e').shipped, true); assert.equal(player(entered, 'e').spice, 19); assert.equal(player(entered, 'e').reserves, 20); assert.deepEqual(player(entered, 'e').forces, {}); assert.ok(player(entered, 'e').noField!.deployed); assert.equal( player(entered, 'e').noField!.tokens.find((t) => t.id === 'zero-marker')! .value, 0, ); const pending = inner(entered, 'm', { type: 'decision', reveal: true }); assert.equal(continuation(pending).entry.amount, 1); assert.equal(continuation(pending).entry.cause, 'shipment'); const settled = recover(pending); assert.equal(settled.pendingTerrorEntry, null); assert.equal(player(settled, 'e').hand.length, 1); assert.deepEqual(player(settled, 'e').noField, player(entered, 'e').noField); assert.equal(player(settled, 'e').spice, 19); assert.equal(player(settled, 'e').reserves, 20); assert.deepEqual(player(settled, 'e').forces, {}); assert.deepEqual(inventory(settled), ids); for (const p of settled.players) viewGame(settled, p.id); });