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 { knowledgeGame, knowledgePhysical, rejectKnowledge } from './truthtrance-knowledge-fixture'; import { createTruthtranceFaceDancerFixture, truthFaceDancerInventory, truthFaceDancerTraitors, truthFaceDancerPlayer } from './fixture-truthtrance-face-dancer'; const player = (g: Game, id: string) => { const p = g.players.find(candidate => candidate.id === id); assert.ok(p); return p; }; const publicLeaders = (g: Game) => g.players.flatMap(p => p.leaders); function namedLeader(g: Game, predicate: (id: string) => boolean) { const roster = publicLeaders(g); const leader = roster.find(item => predicate(item.id) && roster.filter(other => other.name.toLowerCase() === item.name.toLowerCase()).length === 1); assert.ok(leader, 'An eligible unique public leader name must exist'); return leader; } function traitorTarget(g: Game) { const roster = publicLeaders(g); const target = g.players.find(p => p.id !== 'a' && p.traitors.some(id => roster.some(leader => leader.id === id && roster.filter(other => other.name.toLowerCase() === leader.name.toLowerCase()).length === 1))); assert.ok(target, 'A non-asking seat has a uniquely named ordinary Traitor'); return target; } function ask(g: Game, asker: string, target: string, text: string, scope: 'fact' | 'currentTurn' = 'fact') { const card = player(g, asker).hand.find(candidate => candidate.effect === 'truthtrance'); assert.ok(card); let next = applyAction(g, asker, {type:'card', card:card.id}); while (next.truthtrance?.stage === 'priority') { const actor = next.players.find(candidate => !next.truthtrance!.passed.includes(candidate.id)); assert.ok(actor); next = applyAction(next, actor.id, {type:'truthPass'}); } return applyAction(next, asker, {type:'truthAsk', question:{kind:'freeform', target, scope, text}}); } function question(g: Game) { const q = g.truthtrance?.question; assert.equal(q?.kind, 'freeform'); if (q?.kind !== 'freeform') throw Error('Expected freeform question'); return q; } function verify(g: Game, target: string, answer: 'yes' | 'no' | 'unknown') { assert.equal(question(g).interpreted?.version, 9); assert.equal(viewGame(g, target).truthAnswer, answer); for (const seat of g.players.filter(p => p.id !== target)) { const view = viewGame(g, seat.id); assert.equal(view.truthAnswer, null); assert.equal(view.players.find(p => p.id === target)!.traitors, undefined); assert.equal(view.players.find(p => p.id === target)!.faceDancers, undefined); } for (const difficulty of DIFFICULTIES) { const view = viewGame(g, target); view.players.find(p => p.id === target)!.bot = difficulty; assert.deepEqual(botActions(view), [{type:'truthAnswer', answer}]); } for (const wrong of (['yes','no','unknown'] as const).filter(value => value !== answer)) rejectKnowledge(g, target, {type:'truthAnswer', answer:wrong}); const restored = JSON.parse(JSON.stringify(g)) as Game; const done = applyAction(restored, target, {type:'truthAnswer', answer}); if (answer === 'unknown') assert.equal(done.truthtrance?.stage, 'unknown'); else assert.equal(done.truthHistory!.at(-1)!.answer, answer); return done; } void test('V9 checks the actual held and unheld Traitor privately', () => { const initial = knowledgeGame(), target = traitorTarget(initial); const held = namedLeader(initial, id => target.traitors.includes(id)); const unheld = namedLeader(initial, id => !target.traitors.includes(id)); const inventory = knowledgePhysical(initial); for (const [name, answer] of [[held.name,'yes'], [unheld.name,'no']] as const) { const pending = ask(structuredClone(initial), 'a', target.id, `Is ${name} your traitor?`); const done = verify(pending, target.id, answer); assert.deepEqual(knowledgePhysical(done), inventory); } const compound = ask(structuredClone(initial), 'a', target.id, `Is ${held.name} your traitor and do you have at least 0 spice?`); const done = verify(compound, target.id, 'yes'); assert.deepEqual(knowledgePhysical(done), inventory); }); void test('V9 preserves unknown for an unresolved physical Traitor choice', () => { const initial = knowledgeGame(), target = traitorTarget(initial); const held = namedLeader(initial, id => target.traitors.includes(id)); // Stage an unresolved legacy setup choice without creating a second card. target.traitors = target.traitors.filter(id => id !== held.id); target.traitorChoices = [held.id]; const pending = ask(initial, 'a', target.id, `Is ${held.name} your traitor?`); const done = verify(pending, target.id, 'unknown'); assert.deepEqual(player(done,target.id).traitorChoices, [held.id]); }); void test('V9 checks actual native Face Dancer custody separately from Traitors', () => { const f = createTruthtranceFaceDancerFixture(); const held = namedLeader(f.game, id => f.heldIDs.includes(id)); const absent = namedLeader(f.game, id => !f.heldIDs.includes(id)); const original = structuredClone(truthFaceDancerPlayer(f.game, f.respondent).faceDancers); for (const [leader, answer] of [[held,'yes'], [absent,'no']] as const) { const pending = ask(structuredClone(f.game), f.asker, f.respondent, `Do you hold ${leader.name} as an unrevealed Face Dancer?`); const done = verify(pending, f.respondent, answer); assert.deepEqual(truthFaceDancerPlayer(done, f.respondent).faceDancers, original); assert.deepEqual(truthFaceDancerInventory(done), f.originalInventory); assert.deepEqual(truthFaceDancerTraitors(done), f.originalTraitors); } const different = ask(structuredClone(f.game), f.asker, f.respondent, `Is ${held.name} your traitor?`); verify(different, f.respondent, 'no'); }); void test('V9 leaves unstamped and current-turn questions manual and rejects receipt tampering', () => { const initial = knowledgeGame(), target = traitorTarget(initial), held = namedLeader(initial, id => target.traitors.includes(id)); const text = `Is ${held.name} your traitor?`; const future = ask(structuredClone(initial), 'a', target.id, text, 'currentTurn'); assert.equal(question(future).interpreted, undefined); const old = ask(structuredClone(initial), 'a', target.id, text); delete question(old).interpreted; assert.equal(viewGame(old,target.id).truthAnswer, null); assert.equal(applyAction(old,target.id,{type:'truthAnswer',answer:'no'}).truthHistory!.at(-1)!.answer,'no'); const changed = ask(structuredClone(initial), 'a', target.id, text); const q = question(changed); assert.equal(q.interpreted?.version, 9); if (!q.interpreted) throw Error('Expected interpretation'); q.interpreted.fact = {kind:'faceDancer',leader:held.id}; assert.throws(() => viewGame(changed,target.id)); assert.throws(() => applyAction(changed,target.id,{type:'truthAnswer',answer:'yes'})); });