import assert from 'node:assert/strict'; import test from 'node:test'; import { viewGame, type Action, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { actTruthMovementGhola as act, advanceTruthMovementGhola as advance, createTruthMovementBoxGholaFixture as fixture, truthMovementGholaClean as clean, truthMovementGholaInventory as inventory, truthMovementGholaPlayer as player } from './fixture-truth-movement-ghola'; /** Original native Richese Box source, with controlled physical custody and * two staged dead Atreides leaders. Each card is moved, never copied; this is * a targeted composition fixture, not evidence of earned leader deaths. */ function source(withBox: boolean) { const f = fixture(), game = structuredClone(f.game); const actor = player(game, f.target), asker = player(game, f.asker); const gholaIndex = game.discard.findIndex(card => card.id === f.ghola); assert.ok(gholaIndex >= 0); actor.hand.push(game.discard.splice(gholaIndex, 1)[0]); if (!withBox) { const index = actor.hand.findIndex(card => card.id === f.box); assert.ok(index >= 0); game.richeseCache!.push(actor.hand.splice(index, 1)[0]); } const otherTruth = game.deck.findIndex(card => card.effect === 'truthtrance'); assert.ok(otherTruth >= 0, 'The second physical Truthtrance must remain in the undealt deck'); asker.hand.push(game.deck.splice(otherTruth, 1)[0]); const truths = asker.hand.filter(card => card.effect === 'truthtrance'); assert.equal(truths.length, 2); const leaders = actor.leaders.slice(0, 2); for (const leader of leaders) { leader.dead = true; leader.deaths = 1; delete leader.usedAt; } assert.deepEqual(inventory(game), f.originalCards); return { ...f, game, leaders: leaders.map(leader => leader.id), truths: truths.map(card => card.id) }; } function declared(f: ReturnType) { let game = act(f.game, f.asker, { type:'card', card:f.truths[0], cards:[f.truths[1]] }); for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) { const seat = game.players.find(p => !game.truthtrance!.passed.includes(p.id)); assert.ok(seat); game = act(game, seat.id, { type:'truthPass' }); } assert.equal(game.truthtrance?.stage, 'ask'); return game; } function settleDiscard(game: Game): Game { for (let count = 0; game.pendingTreacheryDiscard && count < 20; count++) { const pending = game.pendingTreacheryDiscard; if (pending.reaction?.stage === 'offer') { const passed = pending.reaction.passed; const actor = game.players.find(p => !passed.includes(p.id)); assert.ok(actor); game = act(game, actor.id, { type:'semutaPass', event:pending.batch.event }); } else { assert.ok(!pending.reaction, 'A neutral pass does not select a card'); game = act(game, game.host, { type:'advanceBots' }); } } assert.equal(game.pendingTreacheryDiscard, null); return game; } function ask(game: Game, f: ReturnType, leader: string) { return act(game, f.asker, { type:'truthAsk', question: {kind:'gholaLeader', target:f.target, turn:game.turn, leader} }); } function answer(game: Game, actor: string, value: 'yes' | 'no') { return act(game, actor, {type:'truthAnswer', answer:value}); } function reject(game: Game, actor: string, action: Action) { const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before); } void test('two distinct Ghola Yes answers require a real recovery route', () => { const f = source(false); let game = answer(ask(declared(f), f, f.leaders[0]), f.target, 'yes'); game = ask(settleDiscard(game), f, f.leaders[1]); assert.ok(!viewGame(game, f.target).truthGholaLeaderAnswers?.includes('yes')); reject(game, f.target, {type:'truthAnswer', answer:'yes'}); game = answer(game, f.target, 'no'); assert.equal(game.gholaLeaderPromises!.filter(row => row.answer).length, 1); assert.deepEqual(inventory(game), f.originalCards); }); void test('two named Ghola Yes answers reuse one physical card through the paid Box', () => { const f = source(true); let game = answer(ask(declared(f), f, f.leaders[0]), f.target, 'yes'); game = ask(settleDiscard(game), f, f.leaders[1]); assert.ok(viewGame(game, f.target).truthGholaLeaderAnswers?.includes('yes')); game = answer(game, f.target, 'yes'); assert.equal(game.gholaLeaderPromises!.filter(row => row.answer && !row.fulfilled).length, 2); game = advance(game, clean); const firstGuide = viewGame(game, f.target).gholaLeaderPromiseGuide; assert.ok(firstGuide); assert.equal(firstGuide.action.type, 'card'); for (const difficulty of DIFFICULTIES) { const view = viewGame(game, f.target); view.players.find(seat => seat.id === f.target)!.bot = difficulty; assert.deepEqual(botActions(view)[0], firstGuide.action); } game = act(game, f.target, firstGuide.action); game = advance(game, clean); assert.ok(game.discard.some(card => card.id === f.ghola)); assert.equal(game.gholaLeaderPromises!.filter(row => row.fulfilled).length, 1); const recovery = viewGame(game, f.target).gholaLeaderPromiseGuide; assert.ok(recovery, 'The owner receives the next Box preparation'); assert.equal(recovery.action.type, 'card'); assert.equal(recovery.action.card, f.box); game = act(game, f.target, recovery.action); assert.equal(game.decision?.kind, 'nullentropy'); const search = viewGame(game, f.target).nullentropy!.search!; assert.ok(search); const alternative = search.cards.find(card => card.id !== f.ghola); assert.ok(alternative, 'The paid search must also contain an independently eligible non-Ghola card'); reject(game, f.target, {type:'decision', event:search.event, card:alternative.id}); assert.equal(viewGame(game, f.target).nullentropy!.search!.event, search.event, 'Rejecting an evasion keeps the already-paid search available'); game = act(game, f.target, {type:'decision', event:search.event, card:f.ghola}); game = advance(game, clean); assert.ok(player(game, f.target).hand.some(card => card.id === f.ghola)); const secondGuide = viewGame(game, f.target).gholaLeaderPromiseGuide; assert.ok(secondGuide); assert.equal(secondGuide.action.card, f.ghola); assert.notEqual(secondGuide.action.leader, firstGuide.action.leader); game = advance(act(game, f.target, secondGuide.action), clean); assert.ok(f.leaders.every(id => !player(game, f.target).leaders.find(leader => leader.id === id)!.dead)); assert.equal(game.gholaLeaderPromises!.filter(row => row.answer && row.fulfilled).length, 2); assert.deepEqual(inventory(game), f.originalCards); });