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}; /** Actual Truthtrance declaration, priority, question and response actions. * The shared fixture starts the native game and stages original card custody. */ function ask(state: Game, question: Freeform): Game { const owner = state.players[0]; const card = owner.hand.find(item => item.effect === 'truthtrance'); assert.ok(card); let game = applyAction(state, owner.id, {type:'card', card:card.id}); while (game.truthtrance?.stage === 'priority') { const player = game.players.find(candidate => !game.truthtrance!.passed.includes(candidate.id)); assert.ok(player); game = applyAction(game, player.id, {type:'truthPass'}); } return applyAction(game, owner.id, {type:'truthAsk', question} as Action); } function freeform(game: Game): Freeform { const question = game.truthtrance?.question; assert.equal(question?.kind, 'freeform'); return question as Freeform; } for (const expected of ['yes','no'] as const) for (const kind of ['spice','hand'] as const) { void test(`recognized ${kind} freeform yields verified ${expected} for humans and all bots`, () => { const initial = knowledgeGame(); const target = initial.players.find(player => player.id === 'b')!; let text: string, fact: Stamp['fact']; if (kind === 'spice') { target.spice = expected === 'yes' ? 5 : 4; text = 'Do you have at least 5 spice?'; fact = {kind:'spice', compare:'gte', value:5}; } else { const shield = [...initial.deck, ...initial.players.flatMap(player => player.hand)] .find(item => item.name === 'Shield'); assert.ok(shield); moveSemutaEmperorCard(initial, shield.id, expected === 'yes' ? target.id : 'f'); // No duplicated physical cards and no other Shield face in this target's hand. if (expected === 'no') { for (const card of [...target.hand].filter(item => item.name === 'Shield')) moveSemutaEmperorCard(initial, card.id, 'f'); } text = 'Do you have a Shield?'; fact = {kind:'hand', name:'Shield'}; } const physical = knowledgePhysical(initial); const card = initial.players[0].hand.find(item => item.effect === 'truthtrance')!; const pending = ask(initial, {kind:'freeform', target:target.id, scope:'fact', text}); assert.deepEqual(freeform(pending).interpreted, {version:2, fact}); assert.equal(viewGame(pending, target.id).truthAnswer, expected); for (const id of ['a','f','o']) assert.equal(viewGame(pending,id).truthAnswer, null); rejectKnowledge(pending, target.id, {type:'truthAnswer', answer:expected === 'yes' ? 'no' : 'yes'}); rejectKnowledge(pending, target.id, {type:'truthAnswer', answer:'unknown'}); for (const difficulty of DIFFICULTIES) { const view = viewGame(pending,target.id); view.players.find(player => player.id === target.id)!.bot = difficulty; assert.deepEqual(botActions(view), [{type:'truthAnswer', answer:expected}]); const done = applyAction(structuredClone(pending), target.id, {type:'truthAnswer', answer:expected}); assert.equal(done.truthtrance, null); assert.equal(done.truthHistory!.at(-1)!.answer, expected); assert.equal(done.discard.filter(item => item.id === card.id).length,1); assert.deepEqual(knowledgePhysical(done), physical); } }); } void test('only server interpretation is used when a client forges a stamp', () => { const initial = knowledgeGame(); initial.players.find(player => player.id === 'b')!.spice = 4; const asked = ask(initial, {kind:'freeform', target:'b', scope:'fact', text:'Do you have at least 5 spice?', interpreted:{version:1, fact:{kind:'spice', compare:'gte', value:0}}}); assert.deepEqual(freeform(asked).interpreted, {version:2, fact:{kind:'spice',compare:'gte',value:5}}); assert.equal(viewGame(asked,'b').truthAnswer,'no'); rejectKnowledge(asked,'b',{type:'truthAnswer',answer:'yes'}); const unsupported = ask(initial, {kind:'freeform', target:'b', scope:'fact', text:'Will you win two battles?', interpreted:{version:1, fact:{kind:'spice', compare:'gte', value:0}}}); assert.equal(freeform(unsupported).interpreted, undefined); }); void test('unsupported and current-turn prose retain previous freeform behavior', () => { for (const question of [ {kind:'freeform', target:'b', scope:'fact', text:'Will you win two battles?'}, {kind:'freeform', target:'b', scope:'currentTurn', text:'Do you have at least 5 spice?'}, ] as Freeform[]) { const initial = knowledgeGame(); const pending = ask(initial, question); assert.equal(freeform(pending).interpreted, undefined); for (const difficulty of DIFFICULTIES) { const view = viewGame(pending,'b'); view.players.find(player => player.id === 'b')!.bot = difficulty; assert.deepEqual(botActions(view), [{type:'truthAnswer',answer:'unknown'}]); } const human = applyAction(pending,'b',{type:'truthAnswer',answer:'yes'}); assert.equal(human.truthHistory!.at(-1)!.answer,'yes'); assert.equal(human.truthtrance,null); } }); void test('already-pending unstamped legacy freeform is not reinterpreted on answer', () => { const initial = knowledgeGame(); initial.players.find(player => player.id === 'b')!.spice = 4; const legacy = ask(initial, {kind:'freeform', target:'b', scope:'fact', text:'Do you have at least 5 spice?'}); const pendingQuestion = freeform(legacy); delete pendingQuestion.interpreted; assert.equal(viewGame(legacy,'b').truthAnswer, null); const answered = applyAction(legacy,'b',{type:'truthAnswer',answer:'yes'}); const recorded = answered.truthHistory!.at(-1)!.question as Freeform; assert.equal(recorded.interpreted,undefined); assert.equal(answered.truthHistory!.at(-1)!.answer,'yes'); });