import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, viewGame, 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'; /** Preserve every physical card while staging only the target's hand. */ function pending(names: readonly string[], text: string, scope: 'fact' | 'currentTurn' = 'fact') { let g = knowledgeGame(); const target = g.players.find(p => p.id === 'b')!; for (const card of target.hand.slice()) moveSemutaEmperorCard(g, card.id, 'f'); for (const name of names) { const card = [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand)].find(c => c.name === name); assert.ok(card, `Find physical ${name}`); moveSemutaEmperorCard(g, card.id, target.id); } const truth = g.players[0].hand.find(card => card.effect === 'truthtrance'); assert.ok(truth); g = applyAction(g, 'a', {type: 'card', card: truth.id}); while (g.truthtrance?.stage === 'priority') { const p = g.players.find(row => !g.truthtrance!.passed.includes(row.id)); assert.ok(p); g = applyAction(g, p.id, {type:'truthPass'}); } return applyAction(g, 'a', {type:'truthAsk', question: {kind:'freeform', target:'b', scope, text}}); } function question(g: Game) { const q = g.truthtrance?.question; assert.equal(q?.kind, 'freeform'); if (q?.kind !== 'freeform') throw Error('Expected a freeform question'); return q; } for (const row of [ {name:'both category terms hold', cards:['Chaumas', 'Shield'], text:'Do you have a poison weapon and a defense?', answer:'yes'}, {name:'one category is absent', cards:['Shield'], text:'Do you have a poison weapon and a defense?', answer:'no'}, {name:'named card and negative category', cards:['Shield'], text:'Do you have a Shield and no poison weapons?', answer:'yes'}, {name:'OR accepts the negative alternative', cards:['Shield'], text:'Do you have a poison weapon or no poison weapons?', answer:'yes'}, ] as const) { void test(`v5 ${row.name} enforces the target's actual answer`, () => { const g = pending(row.cards, row.text); assert.equal(question(g).interpreted?.version, 5); assert.equal(viewGame(g, 'b').truthAnswer, row.answer); for (const observer of ['a', 'f', 'o']) assert.equal(viewGame(g, observer).truthAnswer, null); rejectKnowledge(g, 'b', {type:'truthAnswer', answer:row.answer === 'yes' ? 'no' : 'yes'}); rejectKnowledge(g, 'b', {type:'truthAnswer', answer:'unknown'}); for (const difficulty of DIFFICULTIES) { const view = viewGame(g, 'b'); view.players.find(p => p.id === 'b')!.bot = difficulty; assert.deepEqual(botActions(view), [{type:'truthAnswer', answer:row.answer}]); } const physical = knowledgePhysical(g), restored = JSON.parse(JSON.stringify(g)) as Game; const done = applyAction(restored, 'b', {type:'truthAnswer', answer:row.answer}); assert.equal(done.truthHistory!.at(-1)!.answer, row.answer); assert.deepEqual(knowledgePhysical(done), physical); }); } void test('uninterpreted old receipts and current-turn wording keep their existing manual semantics', () => { const text = 'Do you have a Shield and no poison weapons?'; const future = pending(['Shield'], text, 'currentTurn'); assert.equal(question(future).interpreted, undefined); const old = pending(['Shield'], text); delete question(old).interpreted; assert.equal(viewGame(old, 'b').truthAnswer, null); assert.equal(applyAction(old, 'b', {type:'truthAnswer', answer:'no'}).truthHistory!.at(-1)!.answer, 'no'); }); void test('a modified version-5 interpreted receipt is rejected', () => { const g = pending(['Shield'], 'Do you have a Shield and no poison weapons?'); const q = question(g); assert.equal(q.interpreted?.version, 5); if (!q.interpreted) throw Error('Expected the recognized receipt'); q.interpreted.fact = {kind:'hand', name:'Shield'}; assert.throws(() => viewGame(g, 'b')); assert.throws(() => applyAction(g, 'b', {type:'truthAnswer', answer:'yes'})); });