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 { createTruthMovementAdvancedFixture, actTruthMovementAdvanced as act, truthMovementAdvancedClean as clean } from './fixture-truth-movement-advanced'; import { createTruthMovementIxianFixture } from './fixture-truth-movement-ixian'; import { advanceFutureShipment as advance } from './fixture-truthtrance-future-shipment'; import { futureBattleLeaderRecords as records } from './fixture-truth-future-battle-leader'; import { futureBattleLeaderWindow } from '../game/future-battle-leader'; type Fixture = ReturnType; /** The fixture earns the physical Truthtrance and Atreides' Arrakeen army via * native Advanced setup and phase actions. Nothing installs a battle or answer. */ function ask(f: {game: Game; target: string; asker: string; truth: string}): { game: Game; leader: string; other: string } { let game = f.game; const target = game.players.find(player => player.id === f.target)!; const leader = target.leaders[0].id, other = target.leaders[1].id; assert.ok(viewGame(game, f.asker).truthFutureBattleLeaderTargets.includes(f.target)); game = act(game, f.asker, { type: 'card', card: f.truth }); for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) { const player = game.players.find(seat => !game.truthtrance!.passed.includes(seat.id)); assert.ok(player); game = act(game, player.id, { type: 'truthPass' }); } assert.equal(game.truthtrance?.stage, 'ask'); game = act(game, f.asker, { type: 'truthAsk', question: { kind: 'futureBattleLeader', target: f.target, turn: game.turn, leader, } } as unknown as Action); assert.equal(game.truthtrance?.stage, 'answer'); return { game, leader, other }; } function battle(game: Game, f: Fixture): Game { game = act(game, f.target, { type: 'endMovement' }); game = advance(game, state => state.phase === 5 && state.active === f.asker && clean(state)); game = act(game, f.asker, { type: 'ship', amount: 1, elite: 0, territory: 'arrakeen', sector: 10, allyPayment: 0 }); game = advance(game, clean); game = act(game, f.asker, { 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 === 'arrakeen'); assert.ok(choice); game = act(game, choice.chooser, { type: 'chooseBattle', territory: choice.territory, target: choice.attacker === choice.chooser ? choice.defender : choice.attacker }); return advance(game, state => !!state.battle && clean(state) && !state.battle.preparation && state.battle.preLeader?.closed !== false); } for (const answer of ['yes', 'no'] as const) { void test(`Advanced classic ${answer} binds and settles in a real paid-support battle`, () => { const f = createTruthMovementAdvancedFixture({ hajr: false }); const asked = ask(f); assert.ok(viewGame(asked.game, f.target).truthFutureBattleLeaderAnswers?.includes(answer)); let game = act(asked.game, f.target, { type: 'truthAnswer', answer }); assert.equal(records(game)[0].status, 'waiting'); game = battle(game, f); assert.equal(records(game)[0].status, 'bound'); assert.equal(records(game)[0].battle, game.battle!.event); const plan = (leader: string): Action => ({ type: 'battlePlan', leader, dial: 1, support: 1, allyPayment: 0, weapon: null, defense: null }); const rejected = plan(answer === 'yes' ? asked.other : asked.leader); const before = structuredClone(game); assert.throws(() => act(game, f.target, rejected)); assert.deepEqual(game, before); game = act(game, f.target, plan(answer === 'yes' ? asked.leader : asked.other)); const emperor = game.players.find(player => player.id === f.asker)!; game = act(game, f.asker, { type: 'battlePlan', leader: emperor.leaders[0].id, dial: 0, support: 0, allyPayment: 0, weapon: null, defense: null }); assert.ok(game.battle?.revealed); assert.equal(records(game)[0].status, 'settled'); assert.equal(game.discard.filter(card => card.id === f.truth).length, 1); }); } void test('Advanced conditional question expires without requiring a battle', () => { const f = createTruthMovementAdvancedFixture({ hajr: false }); const asked = ask(f); let game = act(asked.game, f.target, { type: 'truthAnswer', answer: 'yes' }); assert.equal(records(game)[0].status, 'waiting'); game = act(game, f.target, { type: 'endMovement' }); game = advance(game, state => state.turn > f.game.turn || state.phase > 6 || state.status === 'finished'); assert.equal(game.battle, null); assert.equal(records(game)[0].status, 'expired'); }); void test('all bot profiles provide an available Advanced conditional answer', () => { const f = createTruthMovementAdvancedFixture({ hajr: false }); const asked = ask(f); for (const difficulty of DIFFICULTIES) { const game = structuredClone(asked.game); game.players.find(player => player.id === f.target)!.bot = difficulty; const reply = botActions(viewGame(game, f.target)).find(action => action.type === 'truthAnswer'); assert.ok(reply, difficulty); const answered = act(game, f.target, reply); assert.equal(records(answered)[0].status, 'waiting'); } }); void test('Advanced Ixians bind and settle their own leader in a real Cyborg battle', () => { const f = createTruthMovementIxianFixture(); const asked = ask(f); let game = act(asked.game, f.target, { type: 'truthAnswer', answer: 'yes' }); assert.equal(records(game)[0].status, 'waiting'); game = act(game, f.target, f.matchingMove); game = advance(game, clean); game = act(game, f.target, { type: 'endMovement' }); game = advance(game, state => state.phase === 5 && state.active === f.asker && clean(state)); game = act(game, f.asker, { type: 'ship', amount: 1, elite: 0, territory: f.destination.territory, sector: f.destination.sector, allyPayment: 0 }); game = advance(game, clean); game = act(game, f.asker, { 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 === f.destination.territory); assert.ok(choice); game = act(game, choice.chooser, { type: 'chooseBattle', territory: choice.territory, target: choice.attacker === choice.chooser ? choice.defender : choice.attacker }); game = advance(game, state => !!state.battle && clean(state) && !state.battle.preparation && state.battle.preLeader?.closed !== false); assert.equal(records(game)[0].status, 'bound'); assert.equal(records(game)[0].battle, game.battle!.event); const common = { dial: 0, support: 0, allyPayment: 0, weapon: null, defense: null }; const before = structuredClone(game); assert.throws(() => act(game, f.target, { type: 'battlePlan', leader: asked.other, ...common })); assert.deepEqual(game, before); game = act(game, f.target, { type: 'battlePlan', leader: asked.leader, ...common }); game = act(game, f.asker, { type: 'battlePlan', leader: game.players.find(player => player.id === f.asker)!.leaders[0].id, ...common }); assert.ok(game.battle?.revealed); assert.equal(records(game)[0].status, 'settled'); }); void test('Advanced optional-module and Ecaz previews remain outside the shared question window', () => { const f = createTruthMovementAdvancedFixture({ hajr: false }); assert.ok(futureBattleLeaderWindow(f.game)); assert.equal(futureBattleLeaderWindow({ ...f.game, homeworlds: { custody: null } }), false); assert.equal(futureBattleLeaderWindow({ ...f.game, ecazOccupyPreview: true }), false); });