Files
Dune/tests/reinforcements-homeworld.test.ts
T
Quintine 1cf90f1328
Verify and publish checkpoint container / container (push) Successful in 11m11s
Allow chosen Reinforcements reserve costs
2026-10-29 04:41:26 +11:00

308 lines
19 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { combatArmy, homeworldBattleLocation } from '../game/combat-location';
import type { NativeReserveSelections } from '../game/homeworld-native-reserves';
import { takeHarassCard } from './fixture-harass-withdraw';
import {
REINFORCEMENTS_CARD, REINFORCEMENTS_KAITAIN as K, REINFORCEMENTS_SALUSA as S,
advanceReinforcementsHomeworld, assertReinforcementsHomeworldCustody,
createReinforcementsHomeworldFixture, finishReinforcementsHomeworldBattle,
reinforcementsHomeworldClean, reinforcementsHomeworldPlayer as player,
reinforcementsHomeworldPool as pool, reinforcementsHomeworldSources as sources,
revealReinforcementsHomeworldBattle, sealReinforcementsHomeworldPlan,
type ReinforcementsHomeworldFixture, type ReinforcementsHomeworldStep,
} from './fixture-reinforcements-homeworld';
function rejectsUnchanged(game: Game, actor: string, action: Action): void {
const before = JSON.stringify(game);
assert.throws(() => applyAction(game, actor, action));
assert.equal(JSON.stringify(game), before, 'Rejected source or dial must not mutate custody, hand or sealed plans');
}
function assertFinished(game: Game, f: ReinforcementsHomeworldFixture): void {
assert.equal(game.battle, null);
assert.ok(!game.homeworldBattleLoss && !game.pendingTreacheryDiscard && reinforcementsHomeworldClean(game));
assert.equal(game.lastBattleContext!.territory, f.location);
assert.equal(game.discard.filter(c => c.id === REINFORCEMENTS_CARD).length, 1);
assert.ok(!player(game, f.owner).hand.some(c => c.id === REINFORCEMENTS_CARD));
assertReinforcementsHomeworldCustody(game);
}
function canonicalSources(allocation: NativeReserveSelections): string {
return JSON.stringify(Object.entries(allocation).filter(([, n]) => n.normal + n.elite > 0)
.sort(([a], [b]) => a.localeCompare(b)));
}
for (const advanced of [false, true]) for (const slot of ['weapon', 'defense'] as const) {
void test(`${advanced ? 'Advanced' : 'Basic'} visiting ${slot} pays three native normals, not its foreign army, on loss`, () => {
const f = createReinforcementsHomeworldFixture({ advanced, slot });
const preview = viewGame(f.game, f.owner).battle!.reinforcements!;
assert.equal(preview.blocked, null);
assert.equal(viewGame(f.game, f.opponent).battle!.reinforcements, null);
assert.equal(viewGame(f.game, f.observer).battle!.reinforcements, null);
let game = sealReinforcementsHomeworldPlan(f.game, f.planActions[0]);
assert.equal(viewGame(game, f.opponent).battle!.plans[f.owner], undefined);
assert.equal(viewGame(game, f.observer).battle!.plans[f.owner], undefined);
assert.deepEqual(game.homeworlds, f.game.homeworlds, 'Sealing is not a premature reserve payment');
game = sealReinforcementsHomeworldPlan(JSON.parse(JSON.stringify(game)) as Game, f.planActions[1]);
assert.deepEqual(viewGame(game, f.owner).battle!.traitorVoters, [f.native]);
rejectsUnchanged(game, f.visitor, { type: 'traitorCall', call: false });
const actions: ReinforcementsHomeworldStep[] = [];
const done = finishReinforcementsHomeworldBattle(game, { actions });
const before = player(f.game, f.owner), after = player(done, f.owner);
assert.equal(done.lastBattleContext!.winner, f.opponent);
assert.equal(done.lastBattleContext!.result, 'normal');
assert.equal(after.reserves, before.reserves - 3);
assert.equal(after.elites!.reserves, before.elites!.reserves);
assert.equal(after.tanks, before.tanks + 10, 'Three fee counters plus the seven actual invaders');
assert.equal(after.elites!.tanks, before.elites!.tanks + 4);
assert.equal(after.battleLosses, before.battleLosses + 10);
assert.equal(after.spice, before.spice - (advanced ? 2 : 0), 'Only the original two supported physical normals cost spice');
assert.deepEqual(pool(done, f.owner, K), advanced ? { normal: 7, elite: 0 } : { normal: 9, elite: 1 });
if (advanced) assert.deepEqual(pool(done, f.owner, S), { normal: 2, elite: 1 });
assert.deepEqual(combatArmy(done, f.owner, f.location), { normal: 0, elite: 0 });
assert.deepEqual(actions.filter(s => s.action.type === 'traitorCall').map(s => s.actor), [f.native]);
assert.equal(after.revived, before.revived); assert.equal(after.freeForcesRevived, before.freeForcesRevived);
assertFinished(done, f);
});
}
void test('actual Advanced native movement exposes all three legal fee allocations and each charges its chosen World after reload', () => {
for (const salusaNormal of [0, 1, 2]) {
const f = createReinforcementsHomeworldFixture({ advanced: true, sources: sources(salusaNormal) });
const choices = viewGame(f.game, f.owner).battle!.reinforcements!.homeworldSources!;
assert.deepEqual(choices.filter(choice => Object.values(choice).every(group => group.elite === 0))
.map(canonicalSources).sort(), [0, 1, 2].map(n => canonicalSources(sources(n))).sort());
assert.ok(choices.some(choice => Object.values(choice).some(group => group.elite > 0)),
'The expanded preview also offers physically available special reserves.');
let game = sealReinforcementsHomeworldPlan(f.game, f.planActions[0]);
game = sealReinforcementsHomeworldPlan(JSON.parse(JSON.stringify(game)) as Game, f.planActions[1]);
const done = finishReinforcementsHomeworldBattle(game);
assert.deepEqual(pool(done, f.owner, K), { normal: 7 + salusaNormal, elite: 0 });
assert.deepEqual(pool(done, f.owner, S), { normal: 2 - salusaNormal, elite: 1 });
assert.equal(player(done, f.owner).tanks, 10);
assert.equal(player(done, f.owner).elites!.tanks, 4);
assertFinished(done, f);
}
});
void test('ambiguous missing, foreign, wrong-type, nonphysical and overdrawn native fee sources reject without changing a real battle', () => {
const f = createReinforcementsHomeworldFixture({ advanced: true });
const missing = { ...f.planActions[0].action }; delete missing.reinforcementsSources;
rejectsUnchanged(f.game, f.owner, missing);
for (const allocation of [
{},
{ 'homeworld:guild': { normal: 3, elite: 0 } },
{ kaitain: { normal: 3, elite: 0 } },
{ [K]: { normal: 2, elite: 1 } },
{ [S]: { normal: 0, elite: 3 } },
{ [S]: { normal: 3, elite: 0 } },
{ [K]: { normal: 4, elite: 0 } },
{ [K]: { normal: 2, elite: 0 } },
{ [K]: { normal: 3 } },
{ [K]: { normal: '3', elite: 0 } },
{ [K]: { normal: 3, elite: 0, visitor: true } },
]) rejectsUnchanged(f.game, f.owner, { ...f.planActions[0].action, reinforcementsSources: allocation } as Action);
rejectsUnchanged(f.game, f.owner, { ...f.planActions[0].action, weapon: null, defense: null });
assert.equal(Object.keys(f.game.battle!.plans).length, 0);
assertReinforcementsHomeworldCustody(f.game);
});
void test('a unique Basic World fee derives its exact source instead of demanding a redundant selector', () => {
const f = createReinforcementsHomeworldFixture({ advanced: false });
const action = { ...f.planActions[0].action }; delete action.reinforcementsSources;
let game = sealReinforcementsHomeworldPlan(f.game, { actor: f.owner, action });
game = sealReinforcementsHomeworldPlan(game, f.planActions[1]);
const done = finishReinforcementsHomeworldBattle(game);
assert.deepEqual(pool(done, f.owner, K), { normal: 9, elite: 1 });
assertFinished(done, f);
});
for (const advanced of [false, true]) for (const role of ['visitor', 'native'] as const) {
void test(`${advanced ? 'Advanced' : 'Basic'} ${role} winner pays its physical dial separately and must discard Reinforcements`, () => {
const f = createReinforcementsHomeworldFixture({ advanced, role, outcome: 'win', slot: 'weapon' });
const before = player(f.game, f.owner);
const done = finishReinforcementsHomeworldBattle(revealReinforcementsHomeworldBattle(f));
const after = player(done, f.owner);
assert.equal(done.lastBattleContext!.winner, f.owner);
assert.equal(after.tanks, before.tanks + 5, 'Three native fee counters and two real dial casualties, no virtual bonus casualties');
assert.equal(after.elites!.tanks, before.elites!.tanks);
assert.equal(after.battleLosses, before.battleLosses + 5);
if (role === 'visitor') assert.ok(done.log.slice(f.game.log.length).some(entry =>
entry.text === `${before.name} received 2 spice from ${f.location}'s occupied bank award.`),
'Completing the last visitor battle includes its actual occupied-World Collection award.');
assert.equal(after.spice, before.spice - (advanced ? 2 : 0) + (role === 'visitor' ? 2 : 0));
if (role === 'visitor') {
assert.deepEqual(combatArmy(done, f.owner, f.location), { normal: 1, elite: 4 });
assert.equal(after.reserves, before.reserves - 3);
} else {
assert.deepEqual(pool(done, f.owner, K), advanced ? { normal: 0, elite: 0 } : { normal: 10, elite: 5 });
assert.equal(after.reserves, before.reserves - 5);
}
if (advanced) assert.deepEqual(pool(done, f.owner, S), pool(f.game, f.owner, S));
assertFinished(done, f);
const continued = advanceReinforcementsHomeworld(done, g => g.phase === 7 && reinforcementsHomeworldClean(g));
assert.equal(continued.turn, f.game.turn);
assertFinished(continued, f);
});
}
void test('own-World fee removes its counters from dial/support availability, while a fee on the other native World does not', () => {
const f = createReinforcementsHomeworldFixture({ advanced: true, role: 'native', nativeTransfer: 12 });
assert.deepEqual(pool(f.game, f.owner, K), { normal: 3, elite: 0 });
assert.deepEqual(pool(f.game, f.owner, S), { normal: 12, elite: 5 });
rejectsUnchanged(f.game, f.owner, { ...f.planActions[0].action, dial: 1, support: 1 });
rejectsUnchanged(f.game, f.owner, { ...f.planActions[0].action, dial: 0.5, support: 0 });
const onWorld = sealReinforcementsHomeworldPlan(f.game, f.planActions[0]);
assert.deepEqual(onWorld.homeworlds, f.game.homeworlds);
const offWorld = sealReinforcementsHomeworldPlan(f.game, { actor: f.owner,
action: { ...f.planActions[0].action, dial: 3, support: 3, reinforcementsSources: sources(3) } });
const offDone = finishReinforcementsHomeworldBattle(
sealReinforcementsHomeworldPlan(offWorld, f.planActions[1]));
assert.equal(offDone.lastBattleContext!.winner, f.owner);
assert.equal(player(offDone, f.owner).tanks, 6, 'The off-World fee leaves three real natives available to win and pay their dial');
assert.deepEqual(pool(offDone, f.owner, K), { normal: 0, elite: 0 });
assert.deepEqual(pool(offDone, f.owner, S), { normal: 9, elite: 5 });
assertFinished(offDone, f);
const done = finishReinforcementsHomeworldBattle(
sealReinforcementsHomeworldPlan(onWorld, f.planActions[1]));
assert.equal(player(done, f.owner).tanks, 3, 'No fee counter is destroyed a second time as a native loser');
assert.deepEqual(pool(done, f.owner, K), { normal: 0, elite: 0 });
assert.deepEqual(pool(done, f.owner, S), { normal: 12, elite: 5 });
assertFinished(done, f);
});
void test('own-World loss retains original high-face strength, not a retroactive low-face bonus after its fee', () => {
const f = createReinforcementsHomeworldFixture({ advanced: true, role: 'native' });
assert.deepEqual(pool(f.game, f.owner, K), { normal: 5, elite: 0 });
assert.equal(homeworldBattleLocation(f.game, K)!.side, 'high');
assert.equal(homeworldBattleLocation(f.game, K)!.nativeBattleStrength, 2);
const opposingLeader = player(f.game, f.opponent).leaders.find(l => l.strength === 3)!;
const game = sealReinforcementsHomeworldPlan(
sealReinforcementsHomeworldPlan(f.game, f.planActions[0]),
{ actor: f.opponent, action: { ...f.planActions[1].action, leader: opposingLeader.id, dial: 3.5, support: 2 } });
// Emperor: original printed 2 + Bashar 2 + Reinforcements 2 = 6.
// Guild: leader 3 + five normals with two support = 6.5.
// Re-reading Kaitain's post-fee low face would illegally give Emperor 7.
const done = finishReinforcementsHomeworldBattle(game);
assert.equal(done.lastBattleContext!.winner, f.opponent);
assert.equal(done.lastBattleContext!.result, 'normal');
assert.equal(player(done, f.owner).tanks, 5, 'Three original fees and the two remaining physical natives');
assert.equal(player(done, f.owner).elites!.tanks, 0);
assert.deepEqual(pool(done, f.owner, K), { normal: 0, elite: 0 });
assert.deepEqual(pool(done, f.owner, S), { normal: 10, elite: 5 });
assertFinished(done, f);
});
for (const advanced of [false, true]) for (const role of ['visitor', 'native'] as const) {
void test(`${advanced ? 'Advanced' : 'Basic'} ${role} pays its original native fee on the sole native's genuine Traitor call`, () => {
const f = createReinforcementsHomeworldFixture({ advanced, role, outcome: 'traitor' });
const revealed = revealReinforcementsHomeworldBattle(f);
assert.deepEqual(viewGame(revealed, f.owner).battle!.traitorVoters, [f.native]);
rejectsUnchanged(revealed, f.visitor, { type: 'traitorCall', call: true });
const actions: ReinforcementsHomeworldStep[] = [];
const done = finishReinforcementsHomeworldBattle(revealed, { callers: [f.native], actions });
assert.equal(done.lastBattleContext!.result, 'traitor');
assert.equal(done.lastBattleContext!.winner, f.native);
assert.equal(player(done, f.owner).tanks, role === 'visitor' ? 10 : 3);
assert.equal(player(done, f.owner).elites!.tanks, role === 'visitor' ? 4 : 0);
assert.equal(player(done, f.owner).reserves, player(f.game, f.owner).reserves - 3);
assert.deepEqual(pool(done, f.owner, K), role === 'visitor'
? advanced ? { normal: 7, elite: 0 } : { normal: 9, elite: 1 }
: advanced ? { normal: 2, elite: 0 } : { normal: 12, elite: 5 });
if (advanced) assert.deepEqual(pool(done, f.owner, S), pool(f.game, f.owner, S));
assert.deepEqual(actions.filter(s => s.action.type === 'traitorCall').map(s => s.actor), [f.native]);
const betrayed = f.native === f.owner ? f.opponent : f.owner;
assert.equal(player(done, betrayed).leaders.find(l => l.id === revealed.battle!.plans[betrayed].leader)!.dead, true);
assertFinished(done, f);
});
}
for (const advanced of [false, true]) for (const role of ['visitor', 'native'] as const) {
void test(`${advanced ? 'Advanced' : 'Basic'} ${role} explosion pays the fee first and preserves the native printed casualty exception`, () => {
const f = createReinforcementsHomeworldFixture({ advanced, role, outcome: 'explosion' });
const nativeBefore = combatArmy(f.game, f.native, f.location);
const printed = homeworldBattleLocation(f.game, f.location)!.nativeBattleStrength;
const revealed = revealReinforcementsHomeworldBattle(f);
const own = revealed.battle!.plans[f.owner], other = revealed.battle!.plans[f.opponent];
const done = finishReinforcementsHomeworldBattle(revealed, { explosionLosses: { normal: printed, elite: 0 } });
assert.equal(done.lastBattleContext!.result, 'explosion');
assert.equal(done.lastBattleContext!.winner, null);
assert.equal(player(done, f.owner).tanks, role === 'visitor' ? 10 : 3 + printed);
assert.equal(player(done, f.owner).elites!.tanks, role === 'visitor' ? 4 : 0);
assert.deepEqual(combatArmy(done, f.native, f.location), {
normal: nativeBefore.normal - printed - (role === 'native' ? 3 : 0), elite: nativeBefore.elite,
});
if (advanced) assert.deepEqual(pool(done, f.owner, S), pool(f.game, f.owner, S));
for (const [actor, plan] of [[f.owner, own], [f.opponent, other]] as const)
assert.equal(player(done, actor).leaders.find(l => l.id === plan.leader)!.dead, true);
for (const id of [REINFORCEMENTS_CARD, own.weapon, other.defense])
assert.equal(done.discard.filter(c => c.id === id).length, 1);
assertFinished(done, f);
});
}
void test('native Salusa explosion uses exact remaining Sardaukar custody after a split normal-first fee', () => {
const f = createReinforcementsHomeworldFixture({ advanced: true, role: 'native', secondary: true,
outcome: 'explosion', sources: sources(2) });
assert.deepEqual(pool(f.game, f.owner, K), { normal: 13, elite: 0 });
assert.deepEqual(pool(f.game, f.owner, S), { normal: 2, elite: 5 });
assert.equal(homeworldBattleLocation(f.game, S)!.nativeBattleStrength, 3);
const done = finishReinforcementsHomeworldBattle(revealReinforcementsHomeworldBattle(f), {
explosionLosses: { normal: 0, elite: 3 },
});
assert.equal(done.lastBattleContext!.result, 'explosion');
assert.equal(player(done, f.owner).reserves, player(f.game, f.owner).reserves - 6);
assert.equal(player(done, f.owner).elites!.reserves, 2);
assert.equal(player(done, f.owner).tanks, 6);
assert.equal(player(done, f.owner).elites!.tanks, 3);
assert.equal(player(done, f.owner).battleLosses, 6);
assert.deepEqual(pool(done, f.owner, K), { normal: 12, elite: 0 });
assert.deepEqual(pool(done, f.owner, S), { normal: 0, elite: 2 });
assertFinished(done, f);
});
void test('a split fee crossing Kaitain low retains the original printed explosion losses', () => {
const f = createReinforcementsHomeworldFixture({ advanced: true, role: 'native',
outcome: 'explosion', sources: sources(2) });
const done = finishReinforcementsHomeworldBattle(revealReinforcementsHomeworldBattle(f), {
explosionLosses: { normal: 2, elite: 0 },
});
assert.equal(done.lastBattleContext!.result, 'explosion');
assert.equal(player(done, f.owner).tanks, 5, 'Three reserve fees plus the original high-face two, not the later low-face three');
assert.equal(player(done, f.owner).elites!.tanks, 0);
assert.deepEqual(pool(done, f.owner, K), { normal: 2, elite: 0 });
assert.deepEqual(pool(done, f.owner, S), { normal: 8, elite: 5 });
assertFinished(done, f);
});
void test('a real named Reinforcements promise finds and executes the off-World fee needed for its fixed native dial', () => {
const f = createReinforcementsHomeworldFixture({ advanced: true, role: 'native', nativeTransfer: 12 });
let game = f.game;
const truth = game.deck.find(card => card.effect === 'truthtrance'); assert.ok(truth);
takeHarassCard(game, f.observer, truth.id);
game = applyAction(game, f.observer, { type: 'card', card: truth.id });
while (game.truthtrance!.stage === 'priority') {
const actor = game.players.find(p => !game.truthtrance!.passed.includes(p.id))!.id;
game = applyAction(game, actor, { type: 'truthPass' });
}
game = applyAction(game, f.observer, { type: 'truthAsk', question: {
kind: 'battlePlan', target: f.owner, claim: { kind: 'and', terms: [
{ kind: 'weapon', name: 'Reinforcements' }, { kind: 'dial', compare: 'eq', value: 3 },
] },
} });
assert.ok(viewGame(game, f.owner).truthBattleAnswers!.includes('yes'));
game = applyAction(game, f.owner, { type: 'truthAnswer', answer: 'yes' });
const witness = viewGame(game, f.owner).battle!.compliantPlan; assert.ok(witness);
rejectsUnchanged(game, f.owner, { type: 'battlePlan', ...witness, reinforcementsSources: sources(0) });
game = applyAction(game, f.owner, { type: 'battlePlan', ...witness });
game = sealReinforcementsHomeworldPlan(game, f.planActions[1]);
const done = finishReinforcementsHomeworldBattle(game);
assert.equal(done.lastBattleContext!.winner, f.owner);
assert.equal(player(done, f.owner).tanks, 6);
assert.equal(player(done, f.owner).spice, player(f.game, f.owner).spice - 3);
assert.deepEqual(pool(done, f.owner, K), { normal: 0, elite: 0 });
assert.deepEqual(pool(done, f.owner, S), { normal: 9, elite: 5 });
assert.equal(done.discard.filter(card => card.id === truth.id).length, 1);
assertFinished(done, f);
});