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, knownForecastGame, knowledgePhysical, rejectKnowledge } from './truthtrance-knowledge-fixture'; /** Native setup and prediction; the fixture stages Truthtrance acquisition. */ function ask(initial: Game, target: string, text: string, scope: 'fact' | 'currentTurn' = 'fact'): Game { const asker = initial.players[0]; const card = asker.hand.find(candidate => candidate.effect === 'truthtrance'); assert.ok(card); let game = applyAction(initial, asker.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, asker.id, { type: 'truthAsk', question: { kind: 'freeform', target, scope, text } }); } function question(game: Game) { const value = game.truthtrance?.question; assert.equal(value?.kind, 'freeform'); if (value?.kind !== 'freeform') throw Error('Expected a freeform question'); return value; } function assertPrivateAndBots(game: Game, target: string, answer: 'yes' | 'no' | 'unknown') { assert.equal(question(game).interpreted?.version, 6); 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 incorrect of (['yes', 'no', 'unknown'] as const).filter(value => value !== answer)) rejectKnowledge(game, target, { type: 'truthAnswer', answer: incorrect }); } void test('V6 prediction questions check private current knowledge and all bot answers', () => { for (const [text, answer] of [['Did you predict Guild?', 'yes'], ['Did you predict Harkonnen?', 'no']] as const) { const initial = knowledgeGame(), physical = knowledgePhysical(initial); const game = ask(initial, 'b', text); assertPrivateAndBots(game, 'b', answer); assert.equal('prediction' in viewGame(game, 'a').players.find(player => player.id === 'b')!, false); const restored = JSON.parse(JSON.stringify(game)) as Game; const done = applyAction(restored, 'b', { type: 'truthAnswer', answer }); assert.equal(done.truthHistory!.at(-1)!.answer, answer); assert.deepEqual(knowledgePhysical(done), physical); } }); void test('V6 unknown knowledge stays private and retains Truthtrance for another question', () => { const initial = knowledgeGame(), before = knowledgePhysical(initial); const game = ask(initial, 'o', 'Did you predict Harkonnen?'); assertPrivateAndBots(game, 'o', 'unknown'); const done = applyAction(game, 'o', { type: 'truthAnswer', answer: 'unknown' }); assert.equal(done.truthtrance?.stage, 'unknown'); assert.deepEqual(knowledgePhysical(done), before); }); void test('V6 uses the native Fremen storm forecast without revealing it to other seats', () => { const initial = knownForecastGame(); assert.ok(initial.stormCard); const game = ask(initial, 'f', `Is your storm forecast exactly ${initial.stormCard}?`); assertPrivateAndBots(game, 'f', 'yes'); for (const id of ['a', 'b', 'o']) assert.equal(viewGame(game, id).stormForecast, null); const done = applyAction(game, 'f', { type: 'truthAnswer', answer: 'yes' }); assert.equal(done.stormCard, initial.stormCard); }); void test('V6 reads the current Atreides Spice forecast only for its entitled owner', () => { const initial = knowledgeGame(); // Stage the Movement forecast and choose an existing undealt worm without // changing the physical Spice deck; acquisition itself is not claimed. initial.phase = 5; initial.active = 'o'; initial.spicePeekKnown = true; const worm = initial.spiceDeck.findIndex(card => 'worm' in card && card.worm); assert.ok(worm >= 0); [initial.spiceDeck[0], initial.spiceDeck[worm]] = [initial.spiceDeck[worm], initial.spiceDeck[0]]; const game = ask(initial, 'o', 'Is the next Spice Blow a sandworm?'); assertPrivateAndBots(game, 'o', 'yes'); const done = applyAction(game, 'o', { type: 'truthAnswer', answer: 'yes' }); assert.deepEqual(done.spiceDeck, initial.spiceDeck); }); void test('V6 checks physical reserve forces using the existing force-count fact', () => { const initial = knowledgeGame(), target = initial.players.find(player => player.id === 'b')!; assert.ok(target.reserves >= 3); const game = ask(initial, 'b', 'Do you have at least 3 forces in your reserves?'); assertPrivateAndBots(game, 'b', 'yes'); const done = applyAction(game, 'b', { type: 'truthAnswer', answer: 'yes' }); assert.equal(done.players.find(player => player.id === 'b')!.reserves, target.reserves); }); void test('older unstamped and current-turn questions keep their existing manual semantics', () => { const text = 'Did you predict Harkonnen?'; const future = ask(knowledgeGame(), 'b', text, 'currentTurn'); assert.equal(question(future).interpreted, undefined); const old = ask(knowledgeGame(), 'b', text); delete question(old).interpreted; assert.equal(viewGame(old, 'b').truthAnswer, null); const done = applyAction(old, 'b', { type: 'truthAnswer', answer: 'yes' }); assert.equal(done.truthHistory!.at(-1)!.answer, 'yes'); }); void test('a modified V6 receipt cannot change the interpreted fact', () => { const game = ask(knowledgeGame(), 'b', 'Did you predict Harkonnen?'); const q = question(game); assert.equal(q.interpreted?.version, 6); if (!q.interpreted) throw Error('Expected a recognized receipt'); q.interpreted.fact = { kind: 'prediction', field: 'faction', faction: 'guild' }; assert.throws(() => viewGame(game, 'b')); assert.throws(() => applyAction(game, 'b', { type: 'truthAnswer', answer: 'yes' })); });