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 { TRUTH_CARD_NAMES } from '../game/truthtrance-card-names'; import { knowledgeBotActions, knowledgeGame, rejectKnowledge } from './truthtrance-knowledge-fixture'; import { createTruthtranceNoFieldFixture, declareNoFieldTruthtrance, passNoFieldTruthPriority, truthtranceNoFieldCards, truthtranceNoFieldPlayer } from './fixture-truthtrance-no-field'; import { advanceTruthMovementNativeEarly, createTruthMovementNativeEarlyFixture, truthMovementNativeEarlyInventory } from './fixture-truth-movement-native-early'; function ask(game: Game, asker: string, target: string, text: string, scope: 'fact' | 'currentTurn' = 'fact') { const waiting = passNoFieldTruthPriority(declareNoFieldTruthtrance(game, asker)); return applyAction(waiting, asker, { type: 'truthAsk', question: { kind: 'freeform', target, scope, text } }); } function question(game: Game) { const q = game.truthtrance?.question; assert.equal(q?.kind, 'freeform'); if (q?.kind !== 'freeform') throw Error('Expected a freeform question'); return q; } function verify(game: Game, target: string, answer: 'yes' | 'no' | 'unknown') { assert.equal(question(game).interpreted?.version, 8); assert.equal(viewGame(game, target).truthAnswer, answer); for (const observer of game.players.filter(player => player.id !== target)) assert.equal(viewGame(game, observer.id).truthAnswer, null); for (const difficulty of DIFFICULTIES) { const view = viewGame(game, target); view.players.find(player => player.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(game, target, { type: 'truthAnswer', answer: wrong }); const restored = JSON.parse(JSON.stringify(game)) 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; } for (const [wording, answer] of [ ['Do you have a concealed No-Field with printed value 3?', 'yes'], ['Do you have a concealed No-Field with printed value 5?', 'no'], ['Do you have a concealed No-Field with printed value 3 and do you have at least 0 forces in your reserves?', 'yes'], ] as const) { void test(`V8 checks the existing concealed marker privately: ${answer}`, () => { const f = createTruthtranceNoFieldFixture({ marker: 3 }); const before = structuredClone(truthtranceNoFieldPlayer(f.game, f.owner).noField); const cards = truthtranceNoFieldCards(f.game); const game = ask(f.game, f.asker, f.owner, wording); for (const player of game.players.filter(player => player.id !== f.owner)) assert.equal(viewGame(game, player.id).richeseNoField!.private, null); const done = verify(game, f.owner, answer); assert.deepEqual(truthtranceNoFieldPlayer(done, f.owner).noField, before); assert.deepEqual(truthtranceNoFieldCards(done), cards); }); } void test('V8 asks about absent No-Field custody without inferring a printed zero', () => { const f = createTruthtranceNoFieldFixture({ marker: 3, boundary: 'beforeShipment' }); const game = ask(f.game, f.asker, f.owner, 'Do you have a concealed No-Field with printed value 0?'); verify(game, f.owner, 'no'); }); /** Real setup and ordinary auction progression; the shared fixture stages * physical Truthtrance acquisition without inventing a card. */ function inspectedAuction() { let game = knowledgeGame(); for (let n = 0; n < 200 && !(game.phase === 3 && game.auction?.peekKnown && !game.response && !game.phaseOpening); n++) { const candidate = game.players.flatMap(player => knowledgeBotActions(game, player.id) .map(action => ({ id: player.id, action })))[0]; assert.ok(candidate, `Expected a native auction action in phase ${game.phase}`); game = applyAction(game, candidate.id, candidate.action); } assert.equal(game.phase, 3); assert.equal(game.auction?.peekKnown, true); assert.ok(game.auction?.cards[game.auction.index]); return game; } void test('V8 checks the actual inspected auction face privately', () => { const initial = inspectedAuction(), card = initial.auction!.cards[initial.auction!.index]; for (const [name, answer] of [[card.name, 'yes'], [card.name === 'Shield' ? 'Snooper' : 'Shield', 'no']] as const) { const game = ask(structuredClone(initial), 'a', 'o', `Is the current auction card you inspected a ${name}?`); for (const id of ['a', 'b', 'f']) assert.equal(viewGame(game, id).auction?.card, null); const done = verify(game, 'o', answer); assert.equal(done.auction!.cards[done.auction!.index].id, card.id); } const missing = structuredClone(initial); missing.auction!.peekKnown = false; const unknown = ask(missing, 'a', 'o', `Is the current auction card you inspected a ${card.name}?`); verify(unknown, 'o', 'unknown'); }); void test('V8 checks the recorded Ixian group rather than the remaining auction lots', () => { const fixture = createTruthMovementNativeEarlyFixture({ roster: 'ixians-tleilaxu' }); let initial = advanceTruthMovementNativeEarly(fixture.game, state => state.phase === 3 && state.decision?.kind === 'ixAuction' && !state.response); const returned = initial.ixAuction!.cards[0].id; initial = applyAction(initial, 'ixians', { type: 'decision', card: returned, position: 'bottom' }); initial = advanceTruthMovementNativeEarly(initial, state => state.phase === 3 && !!state.auction && !!state.ixAuctionKnown && !state.response && !state.decision && !state.phaseOpening); const observed = structuredClone(initial.ixAuctionKnown!.cards), inventory = truthMovementNativeEarlyInventory(initial); const present = observed[0].name, absent = TRUTH_CARD_NAMES.find(name => !observed.some(card => card.name === name)); assert.ok(absent); for (const [name, answer] of [[present, 'yes'], [absent, 'no']] as const) { const game = ask(structuredClone(initial), 'atreides', 'ixians', `Does your recorded Ixian auction group contain ${name}?`); const done = verify(game, 'ixians', answer); assert.deepEqual(done.ixAuctionKnown!.cards, observed); assert.deepEqual(truthMovementNativeEarlyInventory(done), inventory); } const stale = structuredClone(initial); stale.ixAuctionKnown = null; verify(ask(stale, 'atreides', 'ixians', `Does your recorded Ixian auction group contain ${present}?`), 'ixians', 'unknown'); }); void test('V8 preserves unstamped and current-turn questions and rejects changed receipts', () => { const f = createTruthtranceNoFieldFixture({ marker: 3 }); const text = 'Do you have a concealed No-Field with printed value 3?'; const future = ask(structuredClone(f.game), f.asker, f.owner, text, 'currentTurn'); assert.equal(question(future).interpreted, undefined); const old = ask(structuredClone(f.game), f.asker, f.owner, text); delete question(old).interpreted; assert.equal(viewGame(old, f.owner).truthAnswer, null); const legacy = applyAction(old, f.owner, { type: 'truthAnswer', answer: 'no' }); assert.equal(legacy.truthHistory!.at(-1)!.answer, 'no'); const changed = ask(structuredClone(f.game), f.asker, f.owner, text); const q = question(changed); assert.equal(q.interpreted?.version, 8); if (!q.interpreted) throw Error('Expected an interpreted question'); q.interpreted.fact = { kind: 'noFieldValue', value: 5 }; assert.throws(() => viewGame(changed, f.owner)); assert.throws(() => applyAction(changed, f.owner, { type: 'truthAnswer', answer: 'yes' })); });