Files
Dune/tests/truth-future-battle-leader-ecaz.test.ts
T
Quintine 50703664cf
Verify and publish checkpoint container / container (push) Canceled after 0s
Extend conditional battle questions across previews
2026-10-29 02:21:12 +11:00

143 lines
8.2 KiB
TypeScript

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<string, string> = {};
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');
});