import test from 'node:test'; import assert from 'node:assert/strict'; import { applyAction, createGame, newPlayer, viewGame, RuleError, type Game, type Action, } from '../game/engine'; import { baseDeck } from '../game/cards'; import { botActions, runBots } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { truthFactAnswer, truthQuestionText, validateTruthQuestionReceipt, type TruthFact, type TruthQuestion, } from '../game/truthtrance'; import { CHEAP_HERO_TRAITOR } from '../game/traitors'; void test('whole card questions name Amal, Thumper and the actual printed Karama role and shipment beneficiary', () => { const question = (effect: 'amal' | 'thumper' | 'karamaAuction' | 'karamaShipment', source: string) => truthQuestionText({ kind: 'turnActions', target: 'emperor', turn: 2, mixed: { kind: 'action', action: { kind: 'windowCardPlay', effect, source, } } }, id => id, id => id, id => id === 'emperor' ? 'Emperor' : id); assert.match(question('thumper', 'first-spice-reveal:2'), /Thumper.*original first Spice reveal.*ignored or suppressed/); assert.match(question('amal', 'phase-opening:2:8'), /Amal.*original phase opening.*before ordinary payments/); assert.match(question('karamaAuction', 'ordinary-auction:2:1'), /Karama.*original ordinary auction lot.*winning invoice/); assert.match(question('karamaShipment', '["karamaShipment",2,"emperor","original"]'), /Karama.*Emperor’s original unused shipment price/); }); void test('whole Box questions describe future printed activation and its search payment without choosing the recovered card', () => { const question: TruthQuestion = { kind: 'turnActions', target: 'e', turn: 2, mixed: { kind: 'action', action: { kind: 'cardPlay', effect: 'nullentropyBox' } } }; const text = truthQuestionText(question, id => id, id => id); assert.match(text, /activate Nullentropy Box.*printed role.*pay its 2-spice search cost after this answer this turn/); assert.doesNotMatch(text, /Ghola|selected|recover.*Shield/); }); void test('Box raw question parsing needs no hidden possession and eagerly rejects extra selected-card data in folded clauses', () => { const game = fixture(); assert.ok(!game.players.find(player => player.id === 'e')!.hand.some(card => card.effect === 'nullentropyBox')); const action = { kind: 'cardPlay' as const, effect: 'nullentropyBox' as const }; const question: TruthQuestion = { kind: 'turnActions', target: 'e', turn: 2, mixed: { kind: 'or', terms: [{ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 0 } }, { kind: 'action', action }] } }; assert.doesNotThrow(() => validateTruthQuestionReceipt(game, 'a', question)); for (const extra of [{ selected: 'Shield' }, { card: 'physical-box' }, { cost: 2 }, { recipient: 'h' }]) { const invalid = { ...question, mixed: { kind: 'or', terms: [{ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 0 } }, { kind: 'action', action: { ...action, ...extra } }] } }; assert.throws(() => validateTruthQuestionReceipt(game, 'a', invalid as TruthQuestion)); } }); void test('Distrans and Sapho questions name only actual printed use this turn without transfer or order choices', () => { const game = fixture(); for (const effect of ['distrans', 'juiceOfSapho'] as const) { assert.ok(!game.players.find(player => player.id === 'e')!.hand.some(card => card.effect === effect)); const action = { kind: 'cardPlay' as const, effect }; const question: TruthQuestion = { kind: 'turnActions', target: 'e', turn: 2, mixed: { kind: 'or', terms: [{ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 0 } }, { kind: 'action', action }] } }; assert.doesNotThrow(() => validateTruthQuestionReceipt(game, 'a', question)); const text = truthQuestionText(question, id => id, id => id); assert.match(text, effect === 'distrans' ? /complete a printed Distrans transfer and discard Distrans after this answer this turn/ : /play Juice of Sapho in its printed role after this answer this turn/); assert.doesNotMatch(text, /Ghola|first|last|aggressor|private-card|recipient/); for (const extra of [{ recipient: 'h' }, { give: 'private-card' }, { scope: 'movement' }, { mode: 'first' }, { outcome: true }]) { const invalid = { ...question, mixed: { kind: 'or', terms: [{ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 0 } }, { kind: 'action', action: { ...action, ...extra } }] } } as TruthQuestion; assert.throws(() => validateTruthQuestionReceipt(game, 'a', invalid)); } } }); void test('Semuta future-use questions require completed physical exchange without naming a fresh target or inspection', () => { const game = fixture(); assert.ok(!game.players.find(player => player.id === 'e')!.hand.some(card => card.effect === 'semutaDrug')); const action = { kind: 'cardPlay' as const, effect: 'semutaDrug' as const }; const question: TruthQuestion = { kind: 'turnActions', target: 'e', turn: 2, mixed: { kind: 'or', terms: [{ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 0 } }, { kind: 'action', action }] } }; assert.doesNotThrow(() => validateTruthQuestionReceipt(game, 'a', question)); const text = truthQuestionText(question, id => id, id => id); assert.match(text, /complete a printed Semuta Drug exchange and discard Semuta Drug after this answer this turn/); assert.doesNotMatch(text, /inspect|commit|private-fresh-card|Ghola|Sapho/); for (const extra of [{ selected: 'private-fresh-card' }, { committed: true }, { stage: 'select' }, { event: 'discard:2:4:1' }]) { const invalid = { ...question, mixed: { kind: 'or', terms: [{ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 0 } }, { kind: 'action', action: { ...action, ...extra } }] } } as TruthQuestion; assert.throws(() => validateTruthQuestionReceipt(game, 'a', invalid)); } }); void test('Ornithopter and Residual Poison questions name actual native play and the proper deadline without chosen details', () => { const game = fixture(); for (const effect of ['ornithopter', 'residualPoison'] as const) { const action = { kind: 'cardPlay' as const, effect }; const question: TruthQuestion = { kind: 'turnActions', target: 'e', turn: 2, mixed: { kind: 'or', terms: [{ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 0 } }, { kind: 'action', action }] } }; assert.doesNotThrow(() => validateTruthQuestionReceipt(game, 'a', question)); const text = truthQuestionText(question, id => id, id => id); assert.match(text, effect === 'ornithopter' ? /play Ornithopter into a native flight after this answer before your Shipment and Movement turn ends/ : /play Residual Poison in its printed role after this answer this turn/); assert.doesNotMatch(text, /twoGroups|private-leader|killed|Semuta|Ghola/); for (const extra of [{ mode: 'twoGroups' }, { group: 2 }, { victim: 'private-leader' }, { outcome: 'killed' }]) { const invalid = { ...question, mixed: { kind: 'or', terms: [{ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 0 } }, { kind: 'action', action: { ...action, ...extra } }] } } as TruthQuestion; assert.throws(() => validateTruthQuestionReceipt(game, 'a', invalid)); } } }); function fixture() { const g = createGame('TRUTH22', newPlayer('a', 'Atreides', 'atreides')); g.players.push( newPlayer('e', 'Emperor', 'emperor'), newPlayer('h', 'Harkonnen', 'harkonnen'), ); g.status = 'playing'; g.phase = 4; g.turn = 2; g.order = ['e', 'h', 'a']; g.deck = baseDeck(); g.players.forEach((p) => { p.hand = []; p.traitorChoices = []; p.traitors = [p.leaders[0].id]; }); hold(g, 'a', 'Truthtrance'); hold(g, 'h', 'Truthtrance'); hold(g, 'e', 'Shield'); return g; } function hold(g: Game, id: string, name: string) { const index = g.deck.findIndex((c) => c.name === name); assert.ok(index >= 0); const card = g.deck.splice(index, 1)[0]; g.players.find((p) => p.id === id)!.hand.push(card); return card.id; } function declare(g: Game, id = 'a') { return applyAction(g, id, { type: 'card', card: g.players .find((p) => p.id === id)! .hand.find((c) => c.effect === 'truthtrance')!.id, }); } function priority(state: Game) { let g = state; while (g.truthtrance?.stage === 'priority') g = applyAction( g, g.players.find((p) => !g.truthtrance!.passed.includes(p.id))!.id, { type: 'truthPass' }, ); return g; } const fact: TruthQuestion = { kind: 'fact', target: 'e', fact: { kind: 'hand', name: 'Shield' }, }; const ask = (g: Game, question: TruthQuestion = fact, id = 'a') => applyAction(g, id, { type: 'truthAsk', question }); const answer = (g: Game, value = 'yes', id = 'e') => applyAction(g, id, { type: 'truthAnswer', answer: value }); const cards = (g: Game) => [...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)] .map((c) => c.id) .sort(); void test('simultaneous declarations resolve by storm order, independent of network arrival', () => { const initial = fixture(); let g = declare(initial); assert.equal(g.truthtrance!.stage, 'priority'); g = priority(declare(g, 'h')); assert.deepEqual( g.truthtrance!.queue.map((q) => q.player), ['h', 'a'], ); assert.equal(g.discard.length, 0); assert.throws(() => ask(g), /holder to ask/); g = answer(ask(g, fact, 'h')); assert.equal(g.truthtrance!.queue[0].player, 'a'); assert.equal(g.truthtrance!.stage, 'ask'); assert.equal(g.discard.length, 1); g = answer(ask(g)); assert.equal(g.truthtrance, null); assert.equal(g.discard.length, 2); assert.deepEqual(cards(g), cards(initial)); assert.equal(initial.discard.length, 0); assert.equal(initial.truthtrance, undefined); }); void test('private fact answer is visible only to its target until publicly answered', () => { const g = ask(priority(declare(fixture()))); for (const id of ['a', 'h']) { const v = viewGame(g, id); assert.equal(v.truthAnswer, null); assert.equal(v.players.find((p) => p.id === 'e')!.hand, undefined); assert.deepEqual(v.truthHistory, []); assert.equal(v.truthtrance!.question!.kind, 'fact'); } assert.equal(viewGame(g, 'e').truthAnswer, 'yes'); assert.throws(() => answer(g, 'no'), /truthfully/); assert.throws(() => answer(g, 'unknown'), /truthfully/); assert.throws(() => answer(g, 'yes', 'a'), /questioned player/); const done = answer(JSON.parse(JSON.stringify(g))); for (const p of done.players) assert.equal(viewGame(done, p.id).truthHistory[0].answer, 'yes'); assert.equal(viewGame(done, 'a').players[1].hand, undefined); }); void test('AND/OR answers reveal the combined fact rather than the matching private identity', () => { const g = fixture(); g.players[1].traitors.push(CHEAP_HERO_TRAITOR); const yes: TruthFact = { kind: 'traitor', leader: CHEAP_HERO_TRAITOR }; const no: TruthFact = { kind: 'hand', name: 'Lasgun' }; assert.equal( truthFactAnswer(g.players[1], { kind: 'and', terms: [yes, no] }), 'no', ); assert.equal( truthFactAnswer(g.players[1], { kind: 'or', terms: [yes, no] }), 'yes', ); const asked = ask(priority(declare(g)), { kind: 'fact', target: 'e', fact: { kind: 'or', terms: [yes, no] }, }); const done = answer(asked); assert.equal(done.truthHistory![0].answer, 'yes'); assert.deepEqual(Object.keys(done.truthHistory![0]).sort(), [ 'answer', 'asker', 'phase', 'question', 'turn', ]); assert.equal(viewGame(done, 'a').players[1].traitors, undefined); }); void test('any-time question overlays and restores pending windows without reapplying effects', () => { for (const mode of [ 'opening', 'response', 'decision', 'battle', 'spice', ] as const) { const initial = fixture(); if (mode === 'opening') initial.phaseOpening = { passed: ['e'], initialize: true }; if (mode === 'response') { hold(initial, 'a', 'Karama'); initial.response = { kind: 'emperorIncome', owner: 'e', passed: ['h'] }; initial.auction = { cards: initial.deck.splice(0, 2), index: 0, bid: 3, bidder: 'a', active: 'h', passed: [], opener: 0, }; } if (mode === 'decision') { initial.advanced = true; initial.phase = 3; initial.players.find(p => p.id === 'h')!.specialKaramaUsed = true; hold(initial, 'h', 'Shield'); // Preserve a real return choice during the overlay. initial.decision = { kind: 'handExchange', player: 'h', target: 'a', count: 1, }; initial.pendingExchange = { response: { kind: 'harkonnenBonus', owner: 'h', passed: [] }, decision: null, }; } if (mode === 'battle') { initial.phase = 6; initial.battle = { territory: 'arrakeen', attacker: 'a', defender: 'e', prepared: true, plans: { e: { dial: 1, leader: initial.players[1].leaders[0].id, weapon: null, defense: initial.players[1].hand[0].id, support: 0, }, }, revealed: false, traitorCalls: {}, preparation: { kind: 'prescience', owner: 'a', beneficiary: 'a' }, voice: { target: 'e', kind: 'poison', must: false }, }; } if (mode === 'spice') { initial.phase = 1; initial.spiceWindow = { territory: 'red_chasm', sector: 7, amount: 8, harvested: false, }; initial.ready = ['e']; } const before = structuredClone(initial); let g = ask(priority(declare(initial, 'a'))); for (const action of [ { type: 'ready' }, { type: 'passResponse' }, { type: 'decision' }, { type: 'battlePlan', dial: 1 }, { type: 'card', card: initial.players[2].hand[0].id }, { type: 'card', mode: 'special', card: initial.players[2].hand[0].id }, ]) assert.throws(() => applyAction(g, 'h', action), RuleError); g = answer(JSON.parse(JSON.stringify(g))); for (const key of [ 'phaseOpening', 'response', 'decision', 'pendingExchange', 'auction', 'battle', 'spiceWindow', 'ready', 'phase', 'turn', 'active', 'spice', ] as const) assert.deepEqual(g[key], before[key], `${mode}: ${key}`); assert.equal(g.players[1].spice, before.players[1].spice); assert.deepEqual(initial, before); } }); void test('unknown answers permit a different question or save, without discarding the card', () => { const q: TruthQuestion = { kind: 'freeform', target: 'e', text: 'Will you win two battles this turn?', scope: 'fact', }; let g = ask(priority(declare(fixture())), q); g = answer(g, 'unknown'); assert.equal(g.truthtrance!.stage, 'unknown'); assert.equal(g.discard.length, 0); assert.throws( () => applyAction(g, 'e', { type: 'truthSave' }), /can be saved/, ); const saved = applyAction(JSON.parse(JSON.stringify(g)), 'a', { type: 'truthSave', }); assert.equal(saved.truthtrance, null); assert.ok(saved.players[0].hand.some((c) => c.effect === 'truthtrance')); const retried = answer(ask(g)); assert.equal(retried.truthHistory!.length, 2); assert.equal(retried.discard.length, 1); assert.throws( () => applyAction(ask(priority(declare(fixture()))), 'a', { type: 'truthSave', }), /can be saved/, ); }); void test('freeform current-turn promises are public records with explicit turn scope', () => { const question: TruthQuestion = { kind: 'freeform', scope: 'currentTurn', target: 'e', text: 'Will you ship six or more forces to Carthag this turn?', }; const g = answer(ask(priority(declare(fixture())), question), 'no'); assert.equal(g.truthHistory![0].turn, 2); assert.deepEqual(g.truthHistory![0].question, question); for (const p of g.players) assert.deepEqual(viewGame(g, p.id).truthHistory, g.truthHistory); }); void test('malformed questions, targets and forged answers cannot mutate authoritative state', () => { const g = priority(declare(fixture())); const before = JSON.stringify(g); const invalid: unknown[] = [ null, {}, { ...fact, target: 'a' }, { ...fact, target: 'intruder' }, { ...fact, fact: { kind: 'hand', name: '__proto__' } }, { ...fact, fact: { kind: 'traitor', leader: 'missing' } }, { ...fact, fact: { kind: 'and', terms: [] } }, { ...fact, fact: { kind: 'or', terms: Array(9).fill(fact.fact) } }, { kind: 'freeform', target: 'e', text: ' ', scope: 'fact' }, { kind: 'freeform', target: 'e', text: 'x'.repeat(501), scope: 'fact' }, { kind: 'freeform', target: 'e', text: 'Future?', scope: 'nextTurn' }, ]; let nested = fact.fact; for (let i = 0; i < 6; i++) nested = { kind: 'and', terms: [nested, fact.fact] }; invalid.push({ ...fact, fact: nested }); for (const question of invalid) assert.throws( () => applyAction(g, 'a', { type: 'truthAsk', question }), RuleError, ); assert.equal(JSON.stringify(g), before); assert.throws( () => applyAction(ask(g), 'e', { type: 'truthAnswer', answer: true }), /Answer Yes/, ); assert.throws( () => applyAction(fixture(), 'a', { type: 'truthAnswer', answer: 'yes' }), /no pending/, ); }); void test('unselected setup traitors are unknown and a Truthtrance does not finish setup prematurely', () => { const g = fixture(); g.status = 'setup'; g.players[1].traitors = []; g.players[1].traitorChoices = [g.players[1].leaders[1].id]; const q: TruthQuestion = { kind: 'fact', target: 'e', fact: { kind: 'traitor', leader: g.players[1].leaders[0].id }, }; const pending = ask(priority(declare(g)), q); assert.equal(pending.status, 'setup'); assert.equal(viewGame(pending, 'e').truthAnswer, 'unknown'); const unknown = answer(pending, 'unknown'); assert.equal(unknown.status, 'setup'); const saved = applyAction(unknown, 'a', { type: 'truthSave' }); assert.equal(saved.status, 'setup'); assert.deepEqual( saved.players[1].traitorChoices, g.players[1].traitorChoices, ); }); void test('all AI levels pass priority and publish verified answers from their own private view', () => { for (const level of DIFFICULTIES) { const initial = fixture(); initial.players[1].bot = level; initial.players[2].bot = level; let g = runBots(declare(initial)); assert.equal(g.truthtrance!.stage, 'ask'); g = ask(g); assert.deepEqual(botActions(viewGame(g, 'h')), []); assert.deepEqual(botActions(viewGame(g, 'e')), [ { type: 'truthAnswer', answer: 'yes' }, ]); const done = runBots(g, 1); assert.equal(done.truthtrance, null); assert.equal(done.truthHistory![0].answer, 'yes'); } }); void test('AI handles unparseable freeform questions honestly and can complete a declared question', () => { for (const level of DIFFICULTIES) { const initial = fixture(); initial.players[1].bot = level; const q: TruthQuestion = { kind: 'freeform', target: 'e', scope: 'fact', text: 'Will you win two battles?', }; const pending = ask(priority(declare(initial)), q); assert.throws(() => answer(pending, 'yes'), /cannot yet interpret/); const unknown = runBots(pending); assert.equal(unknown.truthtrance!.stage, 'unknown'); assert.equal(unknown.discard.length, 0); const bot = fixture(); bot.players[0].bot = level; let g = priority(declare(bot)); const actions: Action[] = botActions(viewGame(g, 'a')); assert.equal(actions.length, 1); assert.equal(actions[0].type, 'truthAsk'); g = applyAction(g, 'a', actions[0]); const target = g.truthtrance!.question!.target; const actual = viewGame(g, target).truthAnswer!; g = answer(g, actual, target); assert.equal(g.truthtrance, null); } }); void test('truth windows do not bypass lobby or finished-game boundaries and cannot be redeclared after passing', () => { for (const status of ['lobby', 'finished'] as const) { const g = fixture(); g.status = status; assert.throws(() => declare(g), RuleError); } let g = declare(fixture()); assert.throws(() => declare(g), /already declared/); g = applyAction(g, 'h', { type: 'truthPass' }); assert.throws(() => declare(g, 'h'), /already declared/); assert.throws( () => applyAction(g, 'h', { type: 'truthPass' }), /priority window/, ); }); void test('a holder can declare both physical Truthtrances with separate answers and disposal', () => { const initial = fixture(); initial.players[0].hand.push(initial.players[2].hand.pop()!); const ids = initial.players[0].hand.map((c) => c.id); for (const extra of [ [ids[0]], [initial.players[1].hand[0].id], ['forged'], 'not-an-array', ]) assert.throws( () => applyAction(initial, 'a', { type: 'card', card: ids[0], cards: extra }), RuleError, ); let g = priority( applyAction(initial, 'a', { type: 'card', card: ids[0], cards: [ids[1]] }), ); assert.equal(g.truthtrance!.queue.length, 2); g = answer(ask(g)); assert.equal(g.discard.length, 1); assert.equal(g.truthtrance!.queue[0].card, ids[1]); g = answer( ask(g, { kind: 'freeform', target: 'e', text: 'Will you win two battles?', scope: 'fact', }), 'unknown', ); g = applyAction(g, 'a', { type: 'truthSave' }); assert.equal(g.truthtrance, null); assert.deepEqual( g.players[0].hand.map((c) => c.id), [ids[1]], ); assert.deepEqual(cards(g), cards(initial)); });