import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, viewGame, type Action, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { moveSemutaEmperorCard } from './fixture-semuta-emperor-share'; import { knowledgeGame, knowledgePhysical, rejectKnowledge } from './truthtrance-knowledge-fixture'; type Stamp = { version: 1 | 2; fact: unknown }; type Freeform = { kind: 'freeform'; target: string; text: string; scope: 'fact' | 'currentTurn'; interpreted?: Stamp }; /** Native declaration, priority and question; the shared fixture stages only * original physical cards following setup. */ function ask(initial: Game, question: Freeform): Game { const owner = initial.players[0]; const card = owner.hand.find(row => row.effect === 'truthtrance'); assert.ok(card); let game = applyAction(initial, owner.id, {type: 'card', card: card.id}); while (game.truthtrance?.stage === 'priority') { const player = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(player); game = applyAction(game, player.id, {type: 'truthPass'}); } return applyAction(game, owner.id, {type: 'truthAsk', question} as Action); } function question(game: Game): Freeform { const current = game.truthtrance?.question; assert.equal(current?.kind, 'freeform'); return current as Freeform; } function held(game: Game, name: string, present: boolean) { const target = game.players.find(row => row.id === 'b')!; for (const card of [...target.hand].filter(row => row.name === name)) moveSemutaEmperorCard(game, card.id, 'f'); if (present) { const card = [...game.deck, ...game.discard, ...game.players.flatMap(row => row.hand)].find(row => row.name === name); assert.ok(card); moveSemutaEmperorCard(game, card.id, target.id); } } for (const expected of ['yes', 'no'] as const) for (const mode of ['count', 'and', 'or'] as const) { void test(`v2 ${mode} freeform yields only the truthful aggregate ${expected}`, () => { const initial = knowledgeGame(); const target = initial.players.find(row => row.id === 'b')!; let text: string, fact: unknown; if (mode === 'count') { const value = target.hand.length + (expected === 'yes' ? 0 : 1); text = `Do you have at least ${value} cards in your hand?`; fact = {kind: 'handInventory', category: 'all', compare: 'gte', value}; } else if (mode === 'and') { target.spice = expected === 'yes' ? 5 : 4; held(initial, 'Shield', true); text = 'Do you have at least 5 spice and a Shield?'; fact = {kind: 'and', terms: [ {kind: 'spice', compare: 'gte', value: 5}, {kind: 'hand', name: 'Shield'}, ]}; } else { held(initial, 'Shield', expected === 'yes'); held(initial, 'Snooper', false); text = 'Do you have a Shield or a Snooper?'; fact = {kind: 'or', terms: [{kind: 'hand', name: 'Shield'}, {kind: 'hand', name: 'Snooper'}]}; } const physical = knowledgePhysical(initial); const source = initial.players[0].hand.find(row => row.effect === 'truthtrance')!; const pending = ask(initial, {kind: 'freeform', target: 'b', scope: 'fact', text}); assert.deepEqual(question(pending).interpreted, {version: 2, fact}); assert.equal(viewGame(pending, 'b').truthAnswer, expected); for (const observer of ['a', 'f', 'o']) assert.equal(viewGame(pending, observer).truthAnswer, null); rejectKnowledge(pending, 'b', {type: 'truthAnswer', answer: expected === 'yes' ? 'no' : 'yes'}); rejectKnowledge(pending, 'b', {type: 'truthAnswer', answer: 'unknown'}); for (const difficulty of DIFFICULTIES) { const view = viewGame(pending, 'b'); view.players.find(row => row.id === 'b')!.bot = difficulty; assert.deepEqual(botActions(view), [{type: 'truthAnswer', answer: expected}]); const done = applyAction(structuredClone(pending), 'b', {type: 'truthAnswer', answer: expected}); assert.equal(done.truthtrance, null); assert.equal(done.truthHistory!.at(-1)!.answer, expected); assert.equal(done.discard.filter(row => row.id === source.id).length, 1); assert.deepEqual(knowledgePhysical(done), physical); } }); } void test('v2 ignores a forged client interpretation and stamps supported v1 syntax anew', () => { const game = knowledgeGame(); game.players.find(row => row.id === 'b')!.spice = 4; const pending = ask(game, {kind:'freeform', target:'b', scope:'fact', text:'Do you have at least 5 spice?', interpreted: {version:2, fact:{kind:'spice', compare:'gte', value:0}}}); assert.deepEqual(question(pending).interpreted, {version:2, fact:{kind:'spice', compare:'gte', value:5}}); assert.equal(viewGame(pending, 'b').truthAnswer, 'no'); rejectKnowledge(pending, 'b', {type:'truthAnswer', answer:'yes'}); }); void test('unsupported combinations and current-turn questions keep the manual semantics', () => { for (const q of [ {kind:'freeform', target:'b', scope:'fact', text:'Do you have a Shield or a Snooper and a Lasgun?'}, {kind:'freeform', target:'b', scope:'currentTurn', text:'Do you have at least 3 cards in your hand?'}, ] as Freeform[]) { const pending = ask(knowledgeGame(), q); assert.equal(question(pending).interpreted, undefined); assert.equal(viewGame(pending, 'b').truthAnswer, null); for (const difficulty of DIFFICULTIES) { const view = viewGame(pending, 'b'); view.players.find(row => row.id === 'b')!.bot = difficulty; assert.deepEqual(botActions(view), [{type:'truthAnswer', answer:'unknown'}]); } const done = applyAction(pending, 'b', {type:'truthAnswer', answer:'yes'}); assert.equal(done.truthHistory!.at(-1)!.answer, 'yes'); } }); void test('a saved v1-stamped question is validated and answered with v1 semantics', () => { const initial = knowledgeGame(); initial.players.find(row => row.id === 'b')!.spice = 5; const pending = ask(initial, {kind:'freeform', target:'b', scope:'fact', text:'Do you have at least 5 spice?'}); question(pending).interpreted = {version:1, fact:{kind:'spice', compare:'gte', value:5}}; const restored = JSON.parse(JSON.stringify(pending)) as Game; assert.equal(viewGame(restored, 'b').truthAnswer, 'yes'); rejectKnowledge(restored, 'b', {type:'truthAnswer', answer:'no'}); const done = applyAction(restored, 'b', {type:'truthAnswer', answer:'yes'}); assert.deepEqual((done.truthHistory!.at(-1)!.question as Freeform).interpreted, {version:1, fact:{kind:'spice', compare:'gte', value:5}}); });