import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, viewGame, type Action, type Game } from '../game/engine'; import { TERRITORIES, location as locationKey } from '../game/board'; import { advanceToNextStorm } from './fixture-advanced-source'; import { basicEcazOccupyFixture, basicEcazOccupyBattleAction } from './fixture-basic-ecaz-occupy'; import { nextBasicEcazNativeStep } from './fixture-basic-ecaz-native'; import { settleEcazOccupyArrival, stageEcazOccupyCard } from './fixture-ecaz-occupy'; import { futureBattleLeaderRecords as records } from './fixture-truth-future-battle-leader'; const clean = (game: Game) => !game.response && !game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard; function next(game: Game): Game { const step = nextBasicEcazNativeStep(game); assert.ok(step, `The original continuation needs an action in phase ${game.phase}.`); return applyAction(game, step.actor, step.action); } function advance(game: Game, until: (game: Game) => boolean): Game { for (let i = 0; i < 1600 && !until(game); i++) game = next(game); assert.ok(until(game), 'Native continuation did not reach the requested boundary.'); return game; } function arrangeBlow(game: Game, worm: boolean, position: number) { const index = game.spiceDeck.findIndex((card, i) => i >= position && (worm ? 'worm' in card && !card.greatMaker && !card.suppressed : 'territory' in card && card.territory !== 'the_great_flat')); assert.ok(index >= position); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]); } /** The fixture starts with a genuine Basic deal. As in the original Occupy * fixture, conserved board counters are returned, unopened Spice Cards are * reordered, and a rival army, the shipping budgets, two Truthtrance cards and * Karama are staged. Alliance, answers, allied shipments, battle and plan * choices are real native actions. */ function source() { const original = basicEcazOccupyFixture({ chooseBattle: false }); let game = structuredClone(original.afterSetup); for (const player of game.players) { player.reserves += Object.values(player.forces).reduce((a, b) => a + b, 0); player.forces = {}; if (player.elites) { player.elites.reserves += Object.values(player.elites.forces).reduce((a, b) => a + b, 0); player.elites.forces = {}; } } // This fixture deliberately supplies a test-bank budget for the actual // later paid shipments, as the selected battle needs four forces per ally. for (const id of [original.ecaz, original.ally]) game.players.find(p => p.id === id)!.spice = 30; // Stage before either actual auction can move an unopened face to discard. const first = stageEcazOccupyCard(game, original.opponent, card => card.effect === 'truthtrance'); const second = stageEcazOccupyCard(game, original.opponent, card => card.effect === 'truthtrance' && card.id !== first); stageEcazOccupyCard(game, original.opponent, card => card.effect === 'karama'); arrangeBlow(game, false, 0); arrangeBlow(game, false, 1); game = advance(game, g => g.phase === 5 && clean(g)); while (game.phase === 5) game = clean(game) ? applyAction(game, game.active!, { type: 'endMovement' }) : next(game); game = advanceToNextStorm(game); arrangeBlow(game, true, 0); arrangeBlow(game, false, 1); arrangeBlow(game, false, 2); game = advance(game, g => g.phase === 1 && !!g.nexus && !g.spiceWindow && !g.spiceResolution && clean(g)); game = applyAction(game, original.ecaz, { type: 'alliance', target: original.ally }); game = applyAction(game, original.ally, { type: 'alliance', target: original.ecaz }); game = advance(game, g => g.phase === 5 && clean(g)); const sector = TERRITORIES.find(t => t.id === original.territory)!.sectors.find(s => s !== game.storm)!; const location = locationKey(original.territory, sector); const rival = game.players.find(p => p.id === original.opponent)!; assert.ok(rival.reserves >= 8); rival.reserves -= 8; rival.forces[location] = 8; const named: Record = {}; for (const [target, card] of [[original.ecaz, first], [original.ally, second]] as const) { const player = game.players.find(p => p.id === target)!; const leader = player.leaders.find(l => !l.dead && !l.usedAt)!; assert.ok(leader); named[target] = leader.id; assert.ok(viewGame(game, original.opponent).truthFutureBattleLeaderTargets.includes(target)); game = applyAction(game, original.opponent, { type: 'card', card }); while (game.truthtrance?.stage === 'priority') { const actor = game.players.find(p => !game.truthtrance!.passed.includes(p.id)); assert.ok(actor); game = applyAction(game, actor.id, { type: 'truthPass' }); } assert.equal(game.truthtrance?.stage, 'ask'); game = applyAction(game, original.opponent, { type: 'truthAsk', question: { kind: 'futureBattleLeader', target, turn: game.turn, leader: leader.id, } } as Action); game = applyAction(game, target, { type: 'truthAnswer', answer: 'yes' }); assert.equal(records(game).find(row => row.player === target)?.status, 'waiting'); } while (game.phase === 5) { if (!clean(game)) { game = next(game); continue; } const actor = game.active!; if (actor === original.ecaz || actor === original.ally) { game = settleEcazOccupyArrival(applyAction(game, actor, { type: 'ship', territory: original.territory, sector, amount: 4 })); } game = applyAction(game, actor, { type: 'endMovement' }); } game = advance(game, g => g.phase === 6 && clean(g) && !g.battle && !!g.active); return { ...original, game, named, location }; } for (const lead of ['ecaz', 'ally'] as const) for (const canceled of [false, true]) void test(`Basic Occupy ${lead} lead ${canceled ? 'canceled' : 'allowed'} binds only its actual plan owner`, () => { const f = source(); let game = f.game; const battle = basicEcazOccupyBattleAction(f); game = applyAction(game, battle.actor, battle.action); assert.equal(game.decision?.kind, 'ecazBattleLead'); assert.ok(records(game).every(row => row.status === 'waiting' && !row.battle)); const selected = lead === 'ecaz' ? f.ecaz : f.ally; const nonleader = selected === f.ecaz ? f.ally : f.ecaz; game = applyAction(game, f.ecaz, { type: 'decision', event: game.decision!.event, lead: selected }); assert.equal(game.response?.kind, 'ecazOccupy'); assert.ok(records(game).every(row => row.status === 'waiting' && !row.battle)); if (canceled) { const card = game.players.find(p => p.id === f.opponent)!.hand.find(c => c.effect === 'karama'); assert.ok(card); game = applyAction(game, f.opponent, { type: 'card', mode: 'cancel', card: card.id }); } game = advance(game, g => !!g.battle && clean(g) && !g.battle.preparation && (!g.battle.preLeader || g.battle.preLeader.closed)); assert.equal(game.battle!.ecazOccupy!.canceled, canceled); assert.equal(records(game).find(row => row.player === selected)?.status, 'bound'); assert.equal(records(game).find(row => row.player === selected)?.battle, game.battle!.event); assert.equal(records(game).find(row => row.player === nonleader)?.status, 'waiting'); const profile = viewGame(game, selected).battle!.ecazOccupy!.profile!; const wrong = game.players.find(p => p.id === selected)!.leaders.find(l => l.id !== f.named[selected] && !l.dead && !l.usedAt)!; const before = structuredClone(game); assert.throws(() => applyAction(game, selected, { type: 'battlePlan', dial: profile.fixedEcazDial, support: 0, leader: wrong.id })); assert.deepEqual(game, before); game = applyAction(game, selected, { type: 'battlePlan', dial: profile.fixedEcazDial, support: 0, leader: f.named[selected] }); const opponentLeader = game.players.find(p => p.id === f.opponent)!.leaders.find(l => !l.dead && !l.usedAt)!; game = applyAction(game, f.opponent, { type: 'battlePlan', dial: 0, support: 0, leader: opponentLeader.id }); assert.equal(records(game).find(row => row.player === selected)?.status, 'settled'); game = advance(game, g => !g.battle && (g.phase > 6 || g.turn > f.game.turn)); assert.equal(records(game).find(row => row.player === nonleader)?.status, 'expired'); });