Files
Dune/tests/world-phase-battle.test.ts
T

256 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import { combatArmy } from '../game/combat-location';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
import { harassHomeworldPool as pool, finishHarassHomeworldBattle } from './fixture-harass-homeworld';
import { advanceNativeWorldBattle } from './fixture-native-world-battle';
import {
finishReinforcementsHomeworldBattle, revealReinforcementsHomeworldBattle,
} from './fixture-reinforcements-homeworld';
import {
actWorldPhaseBattle, advanceWorldPhaseBattle, assertWorldPhaseBattleInventory,
createWorldPhaseBattleFixture, createWorldPhaseOwnBattleFixture, createWorldPhaseVisitorBattleFixture,
finishWorldPhaseBattle, revealWorldPhaseBattle, worldPhaseBattleClean,
worldPhaseBattlePlayer as player, type WorldPhaseBattleFixture,
} from './fixture-world-phase-battle';
function assertCompletedUtilityBattle(game: Game, f: WorldPhaseBattleFixture): void {
assert.equal(game.turn, 2);
assert.equal(game.phase, 6, 'A second paid native conflict prevents Collection from hiding battle economics');
assert.equal(game.battle, null);
assert.ok(worldPhaseBattleClean(game));
assert.equal(game.lastBattleContext!.winner, f.owner);
assert.equal(game.lastBattleContext!.result, 'normal');
for (const id of [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD]) {
assert.equal(game.discard.filter(card => card.id === id).length, 1);
assert.ok(!player(game, f.owner).hand.some(card => card.id === id));
}
assert.deepEqual(combatArmy(game, f.observer, 'homeworld:guild'), { normal: 1, elite: 0 },
'The original paid Emperor visitor remains outside Player.forces and native reserve totals');
assert.equal(player(game, f.observer).reserves, 19);
assert.deepEqual(pool(game, f.opponent, 'homeworld:guild'), { normal: 11, elite: 0 });
assertWorldPhaseBattleInventory(game);
}
for (const support of [0, 1, 3]) {
void test(`earned Arrakeen + original Tech: support ${support} pays only actual support, then exact native R/H custody`, () => {
const f = createWorldPhaseBattleFixture({ support });
assert.equal(f.beforeFirstMentat.strongholdCards!.owners.arrakeen, null);
assert.equal(player(f.beforeFirstMentat, f.owner).forces[f.location], 10);
assert.equal(f.afterFirstMentat.strongholdCards!.owners.arrakeen, f.owner);
assert.equal(f.afterFirstMentat.strongholdCards!.claimedTurn, 1);
const ownShipment = f.shipments.find(s => s.step.actor === f.owner)!;
const attack = f.shipments.find(s => s.step.actor === f.opponent)!;
assert.equal(player(ownShipment.before, f.owner).spice - player(ownShipment.after, f.owner).spice, 4,
'Four original Caladan counters pay their native four-spice invoice');
assert.equal(player(attack.before, f.opponent).spice - player(attack.after, f.opponent).spice, 2,
'Four original Guild invaders pay their real native half-price invoice');
assert.deepEqual(pool(f.game, f.owner, 'homeworld:atreides'), { normal: 6, elite: 0 });
assert.deepEqual(combatArmy(f.game, f.owner, f.game.battle!.territory), { normal: 14, elite: 0 });
assert.equal(f.game.techTokens!.heighliners.owner, f.opponent);
assert.equal(f.game.techTokens!.heighliners.spice, 0);
const revealed = revealWorldPhaseBattle(f);
assert.equal(player(revealed, f.owner).reserves, 6, 'Sealing and reveal do not prematurely spend counters');
const done = finishWorldPhaseBattle(revealed, f);
const committed = 6 - support;
const returned = 14 - committed;
assert.equal(player(done, f.owner).spice, player(f.game, f.owner).spice - Math.max(0, support - 2),
'Arrakeen covers at most two actually selected support; it never mints personal spice');
assert.equal(player(done, f.owner).tanks, 3 + committed,
'Three off-World native fee counters plus only the physical committed Arrakeen complement die');
assert.equal(player(done, f.owner).battleLosses, 3 + committed);
assert.equal(player(done, f.owner).reserves, 3 + returned,
'Original Caladan6 pays R3 before all undialed board counters return to that actual native pool');
assert.deepEqual(pool(done, f.owner, 'homeworld:atreides'), { normal: 3 + returned, elite: 0 });
assert.deepEqual(combatArmy(done, f.owner, f.game.battle!.territory), { normal: 0, elite: 0 });
assert.equal(player(done, f.opponent).tanks, 4);
assert.equal(player(done, f.opponent).spice, player(f.game, f.opponent).spice,
'Winning does not retroactively redirect the former owner’s Heighliners income');
assert.equal(done.techTokens!.heighliners.owner, f.owner, 'Original winner Tech transfer follows the actual Guild defeat');
assert.equal(done.techTokens!.heighliners.spice, 0);
assert.equal(viewGame(done, f.owner).homeworlds?.worlds?.find(w => w.native === f.owner)?.side, 'high');
assertCompletedUtilityBattle(done, f);
});
}
void test('Advanced earned Strongholds alone: utility fee cannot turn six native reserves into extra Arrakeen dial', () => {
const f = createWorldPhaseBattleFixture({ tech: false, strongholds: true });
const tooMany = structuredClone(f.plans[0].action);
tooMany.dial = 8.5; tooMany.support = 2;
assert.throws(() => applyAction(f.game, f.owner, tooMany),
'Fifteen physical board commitments cannot borrow any of the six native reserves, even with R');
const wrongSource = structuredClone(f.plans[0].action);
wrongSource.reinforcementsSources = { 'homeworld:guild': { normal: 3, elite: 0 } };
assert.throws(() => applyAction(f.game, f.owner, wrongSource), 'Only original native reserves can pay this fee');
const done = finishWorldPhaseBattle(revealWorldPhaseBattle(f), f);
assert.equal(player(done, f.owner).spice, player(f.game, f.owner).spice - 1);
assert.equal(player(done, f.opponent).spice, player(f.game, f.opponent).spice,
'No post-battle income may be fabricated when the Tech module is absent');
assert.equal(player(done, f.owner).tanks, 6);
assert.deepEqual(pool(done, f.owner, 'homeworld:atreides'), { normal: 14, elite: 0 });
assertCompletedUtilityBattle(done, f);
});
void test('Basic Worlds + original Tech: native paid attack and R/H retain Basic forces and no support charge', () => {
const f = createWorldPhaseBattleFixture({ advanced: false, tech: true, strongholds: false });
const tooMany = structuredClone(f.plans[0].action); tooMany.dial = 15;
assert.throws(() => applyAction(f.game, f.owner, tooMany), 'The native fee is never an additional Basic board army');
const done = finishWorldPhaseBattle(revealWorldPhaseBattle(f), f);
assert.equal(player(done, f.owner).spice, player(f.game, f.owner).spice, 'Basic pays no force support');
assert.equal(player(done, f.owner).tanks, 6);
assert.deepEqual(pool(done, f.owner, 'homeworld:atreides'), { normal: 14, elite: 0 });
assert.equal(viewGame(done, f.owner).strongholdCards, null);
assert.equal(done.techTokens!.heighliners.owner, f.owner);
assertCompletedUtilityBattle(done, f);
});
void test('native current Caladan face: a surviving original army consumes the actual reinforcement decision', () => {
const f = createWorldPhaseBattleFixture();
const plans = structuredClone(f.plans);
plans[0].action.weapon = null; plans[0].action.defense = null;
delete plans[0].action.reinforcementsSources;
let game = revealWorldPhaseBattle(f, plans);
game = advanceWorldPhaseBattle(game, g => !g.response && !g.phaseOpening && g.decision?.kind === 'caladanReinforcement');
assert.equal(game.decision!.player, f.owner);
assert.equal(player(game, f.owner).reserves, 6);
assert.equal(player(game, f.owner).tanks, 3);
assert.equal(player(game, f.owner).forces[f.location], 11);
const beforeSpice = player(game, f.owner).spice;
const offer = viewGame(game, f.owner).caladanReinforcement!;
const destination = offer.destinations.find(d => d.id === f.location && !d.blocked); assert.ok(destination);
game = actWorldPhaseBattle(game, { actor: f.owner,
action: { type: 'decision', event: offer.event, destination: destination.id } });
game = advanceWorldPhaseBattle(game, g => !g.battle && worldPhaseBattleClean(g));
assert.equal(player(game, f.owner).reserves, 5, 'Exactly one real Caladan normal counter leaves its native pool');
assert.equal(player(game, f.owner).forces[f.location], 12);
assert.equal(player(game, f.owner).tanks, 3);
assert.equal(player(game, f.owner).spice, beforeSpice, 'The native reinforcement is not a second paid shipment');
assert.equal(viewGame(game, f.owner).homeworlds?.worlds?.find(w => w.native === f.owner)?.side, 'low',
'The actual native6→5 transfer immediately crosses the printed Caladan threshold');
assert.equal(game.lastBattleContext!.caladanReinforcement!.completed, true);
assert.equal(game.techTokens!.heighliners.owner, f.owner);
assertWorldPhaseBattleInventory(game);
});
void test('own-World R: local fee counters are disjoint from dial, off-World normals pay physically, and H remains forbidden', () => {
const f = createWorldPhaseOwnBattleFixture();
assert.deepEqual(pool(f.game, f.owner, 'homeworld:emperor'), { normal: 5, elite: 0 });
assert.deepEqual(pool(f.game, f.owner, 'homeworld:emperor:salusa'), { normal: 10, elite: 5 });
assert.deepEqual(pool(f.game, f.opponent, f.location), { normal: 5, elite: 0 }, 'The original paid Guild invaders retain separate visitor custody');
const local = structuredClone(f.planActions[0].action);
local.dial = 3; local.support = 3;
local.reinforcementsSources = { 'homeworld:emperor': { normal: 3, elite: 0 } };
assert.throws(() => applyAction(f.game, f.owner, local), 'Kaitain5 cannot both pay its own three and dial three');
const elite = structuredClone(f.planActions[0].action);
elite.reinforcementsSources = { 'homeworld:emperor:salusa': { normal: 0, elite: 3 } };
assert.throws(() => applyAction(f.game, f.owner, elite), 'Existing native normals must pay before any real Sardaukar');
const forbidden = structuredClone(f.planActions[0].action); forbidden.weapon = HARASS_WITHDRAW_CARD;
assert.throws(() => applyAction(f.game, f.owner, forbidden), 'An original H card in hand never permits own-World withdrawal');
f.planActions[0].action.dial = 3; f.planActions[0].action.support = 3;
const done = finishReinforcementsHomeworldBattle(revealReinforcementsHomeworldBattle(f));
assert.equal(done.lastBattleContext!.winner, f.owner);
assert.deepEqual(pool(done, f.owner, 'homeworld:emperor'), { normal: 2, elite: 0 });
assert.deepEqual(pool(done, f.owner, 'homeworld:emperor:salusa'), { normal: 7, elite: 5 },
'Exactly three original Salusa normals pay R; none of the five original Sardaukar substitutes');
assert.equal(player(done, f.owner).reserves, 14);
assert.equal(player(done, f.owner).tanks, 6);
assert.equal(player(done, f.owner).elites!.tanks, 0);
assert.equal(player(done, f.opponent).tanks, 5);
assert.deepEqual(pool(done, f.opponent, f.location), { normal: 0, elite: 0 });
assert.equal(done.discard.filter(c => c.id === REINFORCEMENTS_CARD).length, 1);
assert.ok(player(done, f.owner).hand.some(c => c.id === HARASS_WITHDRAW_CARD), 'Rejected H was never used or discarded');
assert.equal(viewGame(done, f.owner).homeworlds?.worlds?.find(w => w.id === f.location)?.side, 'low');
assertWorldPhaseBattleInventory(done);
});
void test('foreign World R+H: original split normal fee precedes exact normal/elite deposits, never tanks or visitor reserves', () => {
const f = createWorldPhaseVisitorBattleFixture();
assert.deepEqual(pool(f.game, f.owner, 'homeworld:emperor'), { normal: 10, elite: 0 });
assert.deepEqual(pool(f.game, f.owner, 'homeworld:emperor:salusa'), { normal: 2, elite: 1 });
assert.deepEqual(pool(f.game, f.owner, f.location), { normal: 3, elite: 4 });
const done = finishHarassHomeworldBattle(revealReinforcementsHomeworldBattle(f),
{ returns: { [f.location]: { normal: 2, elite: 3 } } });
assert.equal(done.lastBattleContext!.winner, f.owner);
assert.deepEqual(pool(done, f.owner, 'homeworld:emperor'), { normal: 11, elite: 0 }, 'Kaitain10 − fee1 + return2');
assert.deepEqual(pool(done, f.owner, 'homeworld:emperor:salusa'), { normal: 0, elite: 4 }, 'Salusa2N1E − fee2N + return3E');
assert.deepEqual(pool(done, f.owner, f.location), { normal: 0, elite: 0 });
assert.equal(player(done, f.owner).reserves, 15);
assert.equal(player(done, f.owner).elites!.reserves, 4);
assert.equal(player(done, f.owner).tanks, 5, 'The fee3N and committed 1N1E die; the undialed 2N3E do not');
assert.equal(player(done, f.owner).elites!.tanks, 1);
assert.equal(player(done, f.opponent).reserves, 0);
assert.equal(player(done, f.opponent).tanks, 15, 'Only the genuine native Junction army dies, not its original five board counters');
assert.equal(player(done, f.opponent).forces['tueks_sietch:5'], 5);
for (const id of [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD]) assert.equal(done.discard.filter(c => c.id === id).length, 1);
assert.equal(done.battle, null); assert.ok(worldPhaseBattleClean(done));
assertWorldPhaseBattleInventory(done);
});
void test('all14 Emperor visitor chooses original typed H returns or committed-only Suk rescue after the same split normal fee', () => {
const f = createWorldPhaseVisitorBattleFixture({ skills: true, skilled: true,
training: { emperor: 'suk-graduate', guild: 'swordmaster-of-ginaz', atreides: 'sandmaster' } });
assert.deepEqual(f.sources, {
'homeworld:emperor': { normal: 1, elite: 0 }, 'homeworld:emperor:salusa': { normal: 2, elite: 0 },
});
assert.equal(viewGame(f.game, f.owner).battle!.strongholdEffects[f.owner], null,
'Original unused modules never subsidize a foreign World');
const trainer = f.planActions[0].action.leader;
const untrained = player(f.game, f.owner).leaders.filter(l => !l.dead && !l.usedAt && l.id !== trainer)
.sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id))[0];
assert.ok(untrained);
f.planActions[0].action.leader = untrained.id;
const withdrawn = finishHarassHomeworldBattle(revealReinforcementsHomeworldBattle(f),
{ returns: { [f.location]: { normal: 2, elite: 3 } } });
assert.deepEqual(pool(withdrawn, f.owner, 'homeworld:emperor'), { normal: 11, elite: 0 });
assert.deepEqual(pool(withdrawn, f.owner, 'homeworld:emperor:salusa'), { normal: 0, elite: 4 });
assert.equal(player(withdrawn, f.owner).tanks, 5);
assert.equal(player(withdrawn, f.owner).elites!.tanks, 1);
assert.equal(withdrawn.lastBattleContext!.sukRescue, undefined,
'A concealed native trainer used as a bluff grants neither normal nor skilled Suk rescue');
assert.deepEqual(viewGame(f.game, f.owner).battle!.traitorVoters, [f.opponent]);
assertWorldPhaseBattleInventory(withdrawn);
// The real alternative keeps H in hand and lets the selected surviving disc
// rescue only the two actually committed visitor casualties.
f.planActions[0].action.defense = null;
f.planActions[0].action.leader = trainer;
let game = advanceNativeWorldBattle(revealReinforcementsHomeworldBattle(f), g => g.decision?.kind === 'sukRescue', [],
{ casualties: { normal: 1, elite: 1 } });
assert.ok(game.decision?.kind === 'sukRescue');
assert.deepEqual(game.pendingSukRescue!.pool, [{ key: f.location, normal: 3, elite: 4 }]);
assert.deepEqual(game.pendingSukRescue!.losses, { normal: 1, elite: 1, paidNormal: 1, paidElite: 0 });
assert.equal(player(game, f.owner).tanks, 3);
assert.equal(player(game, f.owner).elites!.tanks, 0);
assert.ok(game.decision.options.every(o => o.normal + o.elite <= 2), 'The original fee3N cannot enlarge the rescue');
const choice = game.decision.options.findIndex(o => o.normal === 1 && o.elite === 1 && o.kept?.kind === 'normal');
assert.ok(choice >= 0);
const action = { type: 'decision', event: game.decision.event, choice };
const before = structuredClone(game);
assert.throws(() => applyAction(game, f.owner, action), 'Two native homes need a sealed typed rescue allocation');
assert.throws(() => applyAction(game, f.owner, { ...action,
destinations: { [f.location]: { normal: 0, elite: 1 } } }), 'Foreign visitor custody is never a reserve return destination');
assert.deepEqual(game, before);
game = applyAction(game, f.owner, { ...action,
destinations: { 'homeworld:emperor:salusa': { normal: 0, elite: 1 } } });
game = finishReinforcementsHomeworldBattle(game);
assert.equal(game.lastBattleContext!.winner, f.owner);
assert.equal(game.lastBattleContext!.sukRescue!.physical!.source, 'visitor-homeworld');
assert.deepEqual(game.lastBattleContext!.sukRescue!.physical!.destinations,
{ 'homeworld:emperor:salusa': { normal: 0, elite: 1 } });
assert.deepEqual(pool(game, f.owner, 'homeworld:emperor'), { normal: 9, elite: 0 });
assert.deepEqual(pool(game, f.owner, 'homeworld:emperor:salusa'), { normal: 0, elite: 2 });
assert.deepEqual(pool(game, f.owner, f.location), { normal: 3, elite: 3 });
assert.equal(player(game, f.owner).tanks, 3);
assert.equal(player(game, f.owner).elites!.tanks, 0);
assert.equal(player(game, f.owner).battleLosses, 5);
assert.equal(player(game, f.owner).spice, player(f.game, f.owner).spice - 1,
'Native Worlds cannot borrow Arrakeen support or refund the original paid support');
assert.equal(game.discard.filter(c => c.id === REINFORCEMENTS_CARD).length, 1);
assert.ok(player(game, f.owner).hand.some(c => c.id === HARASS_WITHDRAW_CARD));
assertWorldPhaseBattleInventory(game);
});