Files
Dune/tests/harass-homeworld.test.ts
T

211 lines
13 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 {
HARASS_HOMEWORLD_CARD, advanceHarassHomeworld,
assertHarassHomeworldCustody, chooseHarassHomeworldReturn, createHarassHomeworldFixture,
finishHarassHomeworldBattle, harassHomeworldClean, harassHomeworldPlayer as player,
harassHomeworldPool, revealHarassHomeworldBattle, sealHarassHomeworldPlan,
type HarassHomeworldStep,
} from './fixture-harass-homeworld';
function rejectsUnchanged(game: Game, actor: string, action: Action, reason?: RegExp): void {
const before = JSON.stringify(game);
if (reason) assert.throws(() => applyAction(game, actor, action), reason);
else assert.throws(() => applyAction(game, actor, action));
assert.equal(JSON.stringify(game), before, 'An illegal action must not mutate original custody or sealed plans');
}
function assertFinished(game: Game, owner: string, location: string): void {
assert.equal(game.battle, null);
assert.ok(!game.homeworldBattleLoss && !game.pendingTreacheryDiscard && harassHomeworldClean(game));
assert.equal(game.lastBattleContext!.territory, location);
assert.equal(game.discard.filter(c => c.id === HARASS_HOMEWORLD_CARD).length, 1);
assert.ok(!player(game, owner).hand.some(c => c.id === HARASS_HOMEWORLD_CARD));
assertHarassHomeworldCustody(game);
}
for (const sample of [
{ advanced: false, slot: 'weapon' as const },
{ advanced: true, slot: 'defense' as const },
]) void test(`${sample.advanced ? 'Advanced' : 'Basic'} native visitor ${sample.slot} returns actual typed counters, then loses only its original commitment`, () => {
const f = createHarassHomeworldFixture(sample);
for (const state of [f.afterSetup, f.beforeShipment, f.afterShipment, f.beforeBattle, f.game])
assertHarassHomeworldCustody(state);
assert.equal(f.shipmentStep.action.type, 'homeworldShip');
assert.equal(f.battleStep.action.type, 'chooseBattle');
assert.deepEqual(combatArmy(f.beforeShipment, f.visitor, f.location), { normal: 0, elite: 0 });
assert.deepEqual(combatArmy(f.afterShipment, f.visitor, f.location), { normal: 3, elite: 4 });
assert.equal(player(f.afterShipment, f.visitor).reserves, player(f.beforeShipment, f.visitor).reserves - 7);
assert.equal(player(f.afterShipment, f.visitor).elites!.reserves, player(f.beforeShipment, f.visitor).elites!.reserves - 4);
assert.equal(f.game.battle!.harassAllocationVersion, 1);
let game = sealHarassHomeworldPlan(f.game, f.planActions[0]);
assert.ok(!game.battle!.revealed);
assert.equal(game.battle!.harassAllocation, undefined);
assert.equal(viewGame(game, f.opponent).battle!.plans[f.owner], undefined);
const sealedPlan = structuredClone(game.battle!.plans[f.owner]);
assert.equal(sealedPlan[sample.slot], HARASS_HOMEWORLD_CARD);
game = sealHarassHomeworldPlan(game, f.planActions[1]);
assert.equal(game.battle!.revealed, true);
assert.equal(game.decision?.kind, 'harassWithdraw');
assert.deepEqual(game.battle!.plans[f.owner], sealedPlan);
const offer = viewGame(game, f.owner).battle!.harassAllocation!;
assert.deepEqual(offer.context.locations, { [f.location]: { normal: 3, elite: 4 } });
assert.deepEqual(offer.context.forces.normal, 3);
assert.deepEqual(offer.context.forces.elite, 4);
assert.equal(viewGame(game, f.opponent).battle!.harassAllocation, null);
const beforeSelection = structuredClone(game);
game = chooseHarassHomeworldReturn(game, f.returns);
assert.deepEqual(game.battle!.plans[f.owner], sealedPlan);
assert.deepEqual(game.homeworlds, beforeSelection.homeworlds, 'Allocation selection is not a premature withdrawal');
for (const p of game.players) {
assert.equal(p.reserves, player(beforeSelection, p.id).reserves);
assert.equal(p.tanks, player(beforeSelection, p.id).tanks);
}
assert.deepEqual(viewGame(game, f.owner).battle!.traitorVoters, [f.native]);
rejectsUnchanged(game, f.visitor, { type: 'traitorCall', call: false });
const actions: HarassHomeworldStep[] = [];
const done = finishHarassHomeworldBattle(game, { actions });
const returned = f.returns[f.location], amount = returned.normal + returned.elite;
const before = player(f.game, f.owner), after = player(done, f.owner);
assert.equal(done.lastBattleContext!.winner, f.native);
assert.equal(done.lastBattleContext!.result, 'normal');
assert.equal(after.reserves, before.reserves + amount);
assert.equal(after.elites!.reserves, before.elites!.reserves + returned.elite);
assert.equal(after.tanks, before.tanks + 7 - amount);
assert.equal(after.elites!.tanks, before.elites!.tanks + 4 - returned.elite);
assert.equal(after.battleLosses, before.battleLosses + 7 - amount);
assert.equal(after.revived, before.revived);
assert.equal(after.freeForcesRevived, before.freeForcesRevived);
assert.equal(after.elites!.revived, before.elites!.revived);
assert.deepEqual(combatArmy(done, f.owner, f.location), { normal: 0, elite: 0 });
const primary = 'homeworld:emperor';
const homeBefore = harassHomeworldPool(f.game, f.owner, primary);
assert.deepEqual(harassHomeworldPool(done, f.owner, primary), sample.advanced
? { normal: homeBefore.normal + returned.normal, elite: homeBefore.elite }
: { normal: homeBefore.normal + returned.normal, elite: homeBefore.elite + returned.elite });
if (sample.advanced) {
const salusa = harassHomeworldPool(f.game, f.owner, 'homeworld:emperor:salusa');
assert.deepEqual(harassHomeworldPool(done, f.owner, 'homeworld:emperor:salusa'),
{ normal: salusa.normal, elite: salusa.elite + returned.elite });
}
assert.equal(player(done, f.native).tanks - player(f.game, f.native).tanks, 3);
assert.deepEqual(actions.filter(s => s.action.type === 'traitorCall').map(s => s.actor), [f.native]);
assert.equal(after.leaders.find(l => l.id === sealedPlan.leader)!.dead, false, 'Ordinary leader resolution is unchanged');
assertFinished(done, f.owner, f.location);
const continued = advanceHarassHomeworld(done, g => g.phase === 7 && harassHomeworldClean(g));
assert.equal(continued.turn, f.game.turn);
assertFinished(continued, f.owner, f.location);
});
void test('Advanced visitor winner pays its fixed original support and discards Harass instead of retaining it', () => {
const f = createHarassHomeworldFixture({ advanced: true, slot: 'weapon', outcome: 'win' });
let game = revealHarassHomeworldBattle(f);
const sealedPlan = structuredClone(game.battle!.plans[f.owner]);
game = chooseHarassHomeworldReturn(game, f.returns);
assert.deepEqual(game.battle!.plans[f.owner], sealedPlan);
const before = player(f.game, f.owner);
const done = finishHarassHomeworldBattle(game);
const after = player(done, f.owner);
assert.equal(done.lastBattleContext!.winner, f.owner);
assert.equal(after.reserves, before.reserves + 5);
assert.equal(after.tanks, before.tanks + 2);
assert.equal(after.elites!.tanks, before.elites!.tanks + 1);
assert.equal(after.battleLosses, before.battleLosses + 2);
assert.equal(after.spice, before.spice - 1, 'No leader dies here; original one-spice support is charged exactly once');
assert.deepEqual(combatArmy(done, f.owner, f.location), { normal: 0, elite: 0 });
assertFinished(done, f.owner, f.location);
});
for (const sample of [
{ advanced: false, secondary: false, slot: 'weapon' as const },
{ advanced: true, secondary: false, slot: 'defense' as const },
{ advanced: true, secondary: true, slot: 'weapon' as const },
]) void test(`${sample.advanced ? 'Advanced' : 'Basic'} own ${sample.secondary ? 'Salusa' : 'Kaitain'} forbids Harass immutably but accepts the plain original plan`, () => {
const f = createHarassHomeworldFixture({ ...sample, role: 'native' });
assert.equal(homeworldBattleLocation(f.game, f.location)!.native, f.owner);
const preview = viewGame(f.game, f.owner).battle!.harassWithdraw!;
assert.match(preview.blocked!, /own.*Homeworld/i);
rejectsUnchanged(f.game, f.owner, f.planActions[0].action, /own.*Homeworld/i);
assert.equal(Object.keys(f.game.battle!.plans).length, 0);
assert.ok(player(f.game, f.owner).hand.some(c => c.id === HARASS_HOMEWORLD_CARD));
const plain: HarassHomeworldStep = { actor: f.owner, action: { ...f.planActions[0].action, weapon: null, defense: null } };
let game = sealHarassHomeworldPlan(f.game, plain);
game = sealHarassHomeworldPlan(game, f.planActions[1]);
assert.equal(game.battle!.revealed, true);
assert.deepEqual(viewGame(game, f.owner).battle!.traitorVoters, [f.owner]);
const actions: HarassHomeworldStep[] = [];
game = finishHarassHomeworldBattle(game, { actions });
assert.equal(game.battle, null);
assert.ok(harassHomeworldClean(game));
assert.equal(game.lastBattleContext!.territory, f.location);
assert.equal(game.lastBattleContext!.result, 'normal');
assert.ok(player(game, f.owner).hand.some(c => c.id === HARASS_HOMEWORLD_CARD), 'Rejected card was never used');
assert.equal(game.discard.filter(c => c.id === HARASS_HOMEWORLD_CARD).length, 0);
assert.ok(actions.filter(s => s.action.type === 'traitorCall').every(s => s.actor === f.owner));
assertHarassHomeworldCustody(game);
});
void test('the real opposing native Traitor cancels a selected typed withdrawal; no invader vote is invented', () => {
const f = createHarassHomeworldFixture({ advanced: true, slot: 'defense', outcome: 'traitor' });
let game = revealHarassHomeworldBattle(f);
assert.ok(player(game, f.native).traitors.includes(game.battle!.plans[f.owner].leader!));
game = chooseHarassHomeworldReturn(game, f.returns);
assert.deepEqual(viewGame(game, f.owner).battle!.traitorVoters, [f.native]);
rejectsUnchanged(game, f.visitor, { type: 'traitorCall', call: true });
const actions: HarassHomeworldStep[] = [];
const done = finishHarassHomeworldBattle(game, { callers: [f.native], actions });
const before = player(f.game, f.owner), after = player(done, f.owner);
assert.equal(done.lastBattleContext!.result, 'traitor');
assert.equal(done.lastBattleContext!.winner, f.native);
assert.equal(after.reserves, before.reserves);
assert.equal(after.elites!.reserves, before.elites!.reserves);
assert.equal(after.tanks, before.tanks + 7);
assert.equal(after.elites!.tanks, before.elites!.tanks + 4);
assert.equal(after.battleLosses, before.battleLosses + 7);
assert.equal(after.revived, before.revived);
assert.equal(after.elites!.revived, before.elites!.revived);
assert.deepEqual(combatArmy(done, f.owner, f.location), { normal: 0, elite: 0 });
assert.equal(player(done, f.native).tanks, player(f.game, f.native).tanks);
assert.equal(after.leaders.find(l => l.id === f.planActions[0].action.leader)!.dead, true);
assertFinished(done, f.owner, f.location);
});
void test('Harass precedes Lasgun/Shield: returned visitors survive while native losses retain the printed Homeworld exception', () => {
const f = createHarassHomeworldFixture({ advanced: true, slot: 'defense', outcome: 'explosion' });
let game = revealHarassHomeworldBattle(f);
const nativeBefore = combatArmy(f.game, f.native, f.location);
const printed = homeworldBattleLocation(f.game, f.location)!.nativeBattleStrength;
assert.ok(nativeBefore.normal > printed);
const ownPlan = structuredClone(game.battle!.plans[f.owner]);
const enemyPlan = structuredClone(game.battle!.plans[f.opponent]);
assert.equal(player(game, f.owner).hand.find(c => c.id === ownPlan.weapon)!.kind, 'lasgun');
assert.equal(player(game, f.opponent).hand.find(c => c.id === enemyPlan.defense)!.kind, 'shield');
game = chooseHarassHomeworldReturn(game, f.returns);
assert.deepEqual(game.battle!.plans[f.owner], ownPlan);
const actions: HarassHomeworldStep[] = [];
const done = finishHarassHomeworldBattle(game, { actions });
const before = player(f.game, f.owner), after = player(done, f.owner);
assert.equal(done.lastBattleContext!.result, 'explosion');
assert.equal(done.lastBattleContext!.winner, null);
assert.equal(after.reserves, before.reserves + 5);
assert.equal(after.elites!.reserves, before.elites!.reserves + 3);
assert.equal(after.tanks, before.tanks + 2);
assert.equal(after.elites!.tanks, before.elites!.tanks + 1);
assert.equal(after.battleLosses, before.battleLosses + 2);
assert.equal(after.revived, before.revived);
assert.equal(after.freeForcesRevived, before.freeForcesRevived);
assert.equal(after.elites!.revived, before.elites!.revived);
assert.deepEqual(combatArmy(done, f.owner, f.location), { normal: 0, elite: 0 });
assert.deepEqual(combatArmy(done, f.native, f.location), { normal: nativeBefore.normal - printed, elite: nativeBefore.elite });
assert.equal(player(done, f.native).reserves, player(f.game, f.native).reserves - printed);
assert.equal(player(done, f.native).tanks, player(f.game, f.native).tanks + printed);
assert.equal(player(done, f.native).battleLosses, player(f.game, f.native).battleLosses + printed);
assert.equal(after.leaders.find(l => l.id === ownPlan.leader)!.dead, true);
assert.equal(player(done, f.opponent).leaders.find(l => l.id === enemyPlan.leader)!.dead, true);
for (const id of [HARASS_HOMEWORLD_CARD, ownPlan.weapon, enemyPlan.defense])
assert.equal(done.discard.filter(c => c.id === id).length, 1);
assert.deepEqual(actions.filter(s => s.action.type === 'traitorCall').map(s => s.actor), [f.native]);
assertFinished(done, f.owner, f.location);
});