import assert from 'node:assert/strict'; import test from 'node:test'; import { viewGame, type Game } from '../game/engine'; import { TERRITORIES } from '../game/board'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { nextOccupyRichese, finishOccupyRichese } from './fixture-ecaz-occupy-richese'; import { act, inventory, player, createSaphoTruthOrderFixture, truthOrderFrame as frame, playSaphoTruthOrder as play, finishTruthOrder as finish, finishOriginalTruthQuestions as answer, passFreshDiscard } from './fixture-sapho-truth-order'; const clean = (game: Game) => !game.response && !game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard && !game.truthtrance; function advance(game: Game, until: (game: Game) => boolean): Game { for (let i = 0; i < 200 && !until(game); i++) game = nextOccupyRichese(game); assert.ok(until(game), 'The native battle must reach the requested control.'); return game; } /** Genuine public Basic setup and paid cache purchase, then two native paid * shipments and the actual battle choice. The inherited fixture swaps one * original Truthtrance with another held card so its real Sapho holder can * ask; the claim supplement swaps the original cached Semuta into Atreides' * hand. No phase, battle, plan, answer or spice balance is installed. */ function battleSource(mode: 'first' | 'last', semutaClaim = false) { const f = createSaphoTruthOrderFixture({ mode }); let game = structuredClone(f.before); if (semutaClaim) { const claimant = player(game, 'atreides'), cache = game.richeseCache; assert.ok(cache && claimant.hand.length); const at = cache.findIndex(card => card.effect === 'semutaDrug'); assert.ok(at >= 0); const [semuta] = cache.splice(at, 1), [exchange] = claimant.hand.splice(0, 1); claimant.hand.push(semuta); cache.push(exchange); } assert.deepEqual(inventory(game), f.originalCards); const site = TERRITORIES.find(t => t.id === 'the_great_flat')!; const sector = site.sectors.find(value => value !== game.storm); assert.notEqual(sector, undefined); while (game.phase === 5) { if (!clean(game)) { game = nextOccupyRichese(game); continue; } const actor = game.active!; if (actor === 'atreides' || actor === 'emperor') { game = act(game, actor, { type: 'ship', territory: site.id, sector, amount: 1 }); game = advance(game, clean); } game = act(game, actor, { type: 'endMovement' }); } game = advance(game, state => state.phase === 6 && clean(state) && !!state.active && !state.battle); const choice = viewGame(game, game.active!).battleChoices.find(row => row.territory === site.id); assert.ok(choice); game = act(game, choice.chooser, { type: 'chooseBattle', territory: site.id, target: choice.attacker === choice.chooser ? choice.defender : choice.attacker }); game = advance(game, state => !!state.battle && clean(state) && !state.battle.preparation && (!state.battle.preLeader || state.battle.preLeader.closed)); const battle = structuredClone(game.battle); for (const actor of [f.owner, f.other]) { const truth = player(game, actor).hand.find(card => card.effect === 'truthtrance'); assert.ok(truth); game = act(game, actor, { type: 'card', card: truth.id }); } while (game.truthtrance?.stage === 'priority') { const seat = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(seat); game = act(game, seat.id, { type: 'truthPass' }); } const current = frame(game); assert.deepEqual([...current.holders].sort(), [f.owner, f.other].sort()); assert.equal(mode === 'first' ? game.truthtrance!.queue.at(-1)!.player : game.truthtrance!.queue[0].player, f.owner); assert.deepEqual(game.battle, battle); return { ...f, game, battle, semutaClaim }; } for (const mode of ['first', 'last'] as const) for (const claim of [false, true]) void test(`Basic battle Truthtrance Sapho ${mode} survives Semuta ${claim ? 'claim' : 'pass'} and the native battle`, () => { const f = battleSource(mode, claim); const before = structuredClone(f.game.battle), order = [...f.game.order]; const option = viewGame(f.game, f.owner).saphoTruthOrderChoices.find(row => row.card === f.card && row.mode === mode); assert.ok(option); let game = play(f.game, f.owner, f.card, mode); assert.ok(game.pendingTreacheryDiscard?.reaction); if (claim) { const event = game.pendingTreacheryDiscard.batch.event; assert.equal(viewGame(game, 'atreides').semutaReaction?.canCommit, true); game = act(game, 'atreides', { type: 'semutaCommit', event }); assert.ok(player(game, 'atreides').hand.some(card => card.id === f.card)); } else game = passFreshDiscard(game); assert.equal(game.pendingTreacheryDiscard, null); assert.deepEqual(game.battle, before, 'No battle plans, preparations or original event are replayed or changed.'); assert.deepEqual(game.order, order); game = finish(game); assert.equal(mode === 'first' ? game.truthtrance!.queue[0].player : game.truthtrance!.queue.at(-1)!.player, f.owner); game = answer(game); assert.equal(game.truthtrance, null); assert.deepEqual(game.battle, before); const battle = game.battle!; for (const actor of [battle.attacker, battle.defender]) { const leader = player(game, actor).leaders.find(row => !row.dead && !row.usedAt); assert.ok(leader); game = act(game, actor, { type: 'battlePlan', dial: 0, support: 0, leader: leader.id }); } assert.ok(game.battle?.revealed); game = finishOccupyRichese(game); assert.equal(game.battle, null); assert.deepEqual(inventory(game), f.originalCards); }); void test('All minimal bot profiles can use the Basic battle Truth-order window legally', () => { const f = battleSource('first'); for (const difficulty of DIFFICULTIES) for (const actor of [f.owner, f.other]) { const view = viewGame(f.game, actor); view.players.find(row => row.id === actor)!.bot = difficulty; const action = botActions(view)[0]; assert.ok(action && (action.type === 'truthOrderPass' || action.type === 'saphoTruthOrder')); const game = act(f.game, actor, action); assert.deepEqual(game.battle, f.battle); } }); void test('Battle Sapho continuation rejects a changed battle and keeps committed cards reserved', () => { const f = battleSource('first'); // Controlled reservation probe: put the actual Sapho into the owner's // sealed weapon slot to exercise the shared reservation guard directly. const reserved = structuredClone(f.game); reserved.battle!.plans[f.owner] = { leader: null, dial: 0, support: 0, weapon: f.card, defense: null }; assert.deepEqual(viewGame(reserved, f.owner).saphoTruthOrderChoices, []); const pending = play(f.game, f.owner, f.card, 'first'); assert.ok(pending.pendingTreacheryDiscard?.reaction); const changed = structuredClone(pending); changed.battle!.event = 'different-battle'; const snapshot = structuredClone(changed); assert.throws(() => passFreshDiscard(changed)); assert.deepEqual(changed, snapshot); });