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

163 lines
11 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 } from '../game/engine';
import { casualtyOptions } from '../game/combat';
import { homeworldContext } from '../game/homeworld-game';
import { homeworldPopulations } from '../game/homeworld-population';
import { homeworldSardaukarFreeSupport } from '../game/homeworld-benefits';
import {
advanceHarassHomeworldArrakis, assertHarassHomeworldArrakisCustody,
chooseHarassHomeworldArrakisAllocation, createHarassHomeworldArrakisFixture,
finishHarassHomeworldArrakisBattle, harassHomeworldArrakisClean,
harassHomeworldArrakisPlayer as player, harassHomeworldArrakisPool as pool,
pendingHarassHomeworldArrakisAllocations, revealHarassHomeworldArrakisBattle,
sealHarassHomeworldArrakisPlan, type HarassHomeworldArrakisStep,
} from './fixture-harass-homeworld-arrakis';
for (const advanced of [false, true]) {
for (const outcome of ['winner', 'loser'] as const) {
for (const slot of ['weapon', 'defense'] as const) {
void test(`${advanced ? 'Advanced' : 'Basic'} ${outcome} ${slot}: every real typed return preserves its original commitment`, () => {
const f = createHarassHomeworldArrakisFixture({ advanced, outcome, slot });
const onePlan = sealHarassHomeworldArrakisPlan(f.game, f.planActions[0]);
assert.equal(onePlan.battle!.harassAllocation, undefined);
assert.equal(viewGame(onePlan, f.opponent).battle!.plans[f.owner], undefined);
const revealed = sealHarassHomeworldArrakisPlan(onePlan, f.planActions[1]);
assert.equal(revealed.battle!.revealed, true);
assert.equal(revealed.decision?.kind, 'harassWithdraw');
assert.equal(viewGame(revealed, f.opponent).battle!.harassAllocation, null);
const offer = viewGame(revealed, f.owner).battle!.harassAllocation!;
const allocations = pendingHarassHomeworldArrakisAllocations(revealed, f.owner);
assert.ok(allocations.length >= 3, 'Actual physical Basic/Advanced alternatives remain after reveal');
const sealedPlan = structuredClone(revealed.battle!.plans[f.owner]);
const event = revealed.battle!.event;
for (const returns of allocations) {
const returned = returns[f.location];
const committed = { normal: 3 - returned.normal, elite: 4 - returned.elite };
assert.ok(casualtyOptions(offer.context.forces, offer.dial, offer.support)
.some(c => c.normal === committed.normal && c.elite === committed.elite));
let game = chooseHarassHomeworldArrakisAllocation(revealed, f.owner, returns);
assert.deepEqual(game.battle!.plans[f.owner], sealedPlan);
assert.equal(player(game, f.owner).reserves, 13, 'Selection alone does not return counters before Traitor calls');
assert.deepEqual(game.homeworlds, revealed.homeworlds);
game = advanceHarassHomeworldArrakis(game, g => g.lastBattleContext?.event === event);
assert.equal(game.lastBattleContext!.winner, outcome === 'winner' ? f.owner : f.opponent);
assert.equal(game.lastBattleContext!.result, 'normal');
assert.equal(player(game, f.owner).spice, player(revealed, f.owner).spice - (advanced ? 1 : 0), 'Sealed support charged once, not from returned population');
const opponentCollection = advanced && game.phase >= 7 && (player(game, f.opponent).forces[f.location] ?? 0) > 0 ? 1 : 0;
assert.equal(player(game, f.opponent).spice, player(revealed, f.opponent).spice - (advanced && outcome === 'loser' ? 3 : 0) + opponentCollection);
if (outcome === 'winner' && game.decision?.kind === 'battleLosses') {
assert.deepEqual(game.decision.options.map(c => ({ normal: c.normal, elite: c.elite })), [committed], 'The selected Harass complement fixes winner losses');
}
game = finishHarassHomeworldArrakisBattle(game);
const own = player(game, f.owner);
assert.equal(own.reserves, 13 + returned.normal + returned.elite);
assert.equal(own.elites!.reserves, 1 + returned.elite);
assert.equal(own.tanks, committed.normal + committed.elite);
assert.equal(own.elites!.tanks, committed.elite);
assert.equal(own.battleLosses, committed.normal + committed.elite, 'Returns are not battle losses');
assert.equal(own.forces[f.location] ?? 0, 0, 'Returned counters cannot survive as occupying board forces');
assert.equal(own.elites!.forces[f.location] ?? 0, 0);
const other = player(game, f.opponent);
assert.equal(other.reserves, 13, 'Opponent never receives the Harass user’s returned forces');
assert.equal(other.tanks, outcome === 'winner' ? 7 : 3);
assert.equal(other.battleLosses, outcome === 'winner' ? 7 : 3);
assert.equal(other.forces[f.location] ?? 0, outcome === 'winner' ? 0 : 4);
assert.deepEqual(pool(game, f.owner, 'homeworld:emperor'), advanced
? { normal: 12 + returned.normal, elite: 0 }
: { normal: 12 + returned.normal, elite: 1 + returned.elite });
assert.deepEqual(game.homeworlds!.custody!.salusa, advanced ? { normal: 0, elite: 1 + returned.elite } : null);
assert.deepEqual(game.homeworlds!.custody!.visitors, {});
assert.equal(game.discard.filter(c => c.id === 'ecaz-harass-withdraw').length, 1);
assert.ok(!own.hand.some(c => c.id === 'ecaz-harass-withdraw'));
assert.equal(game.battle, null);
assert.ok(harassHomeworldArrakisClean(game));
assertHarassHomeworldArrakisCustody(game);
}
});
}
}
for (const caller of ['opponent', 'owner', 'both'] as const) {
void test(`${advanced ? 'Advanced' : 'Basic'} actual ${caller} Traitor call keeps native return and disposal precedence`, () => {
const f = createHarassHomeworldArrakisFixture({ advanced, traitor: caller, outcome: 'loser' });
const revealed = revealHarassHomeworldArrakisBattle(f);
const returns = { [f.location]: { normal: 2, elite: 3 } };
assert.ok(pendingHarassHomeworldArrakisAllocations(revealed, f.owner)
.some(choice => JSON.stringify(choice) === JSON.stringify(returns)));
const callers = caller === 'both' ? [f.owner, f.opponent] : [caller === 'owner' ? f.owner : f.opponent];
const actions: HarassHomeworldArrakisStep[] = [];
const game = finishHarassHomeworldArrakisBattle(revealed, { returns, callers }, actions);
assert.equal(actions.filter(s => s.action.type === 'traitorCall' && s.action.call === true).length, callers.length);
assert.equal(game.lastBattleContext!.result, caller === 'both' ? 'mutualTraitors' : 'traitor');
assert.equal(game.lastBattleContext!.winner, caller === 'both' ? null : caller === 'owner' ? f.owner : f.opponent);
const own = player(game, f.owner), canceled = caller !== 'owner';
assert.equal(own.reserves, canceled ? 13 : 18);
assert.equal(own.elites!.reserves, canceled ? 1 : 4);
assert.equal(own.tanks, canceled ? 7 : 0);
assert.equal(own.elites!.tanks, canceled ? 4 : 0);
assert.equal(own.battleLosses, canceled ? 7 : 0);
assert.equal(own.forces[f.location] ?? 0, canceled ? 0 : 2, 'Own Traitor winner keeps its committed counters, not its returned counters');
assert.equal(own.elites!.forces[f.location] ?? 0, canceled ? 0 : 1);
assert.deepEqual(pool(game, f.owner, 'homeworld:emperor'), advanced
? { normal: canceled ? 12 : 14, elite: 0 }
: { normal: canceled ? 12 : 14, elite: canceled ? 1 : 4 });
assert.deepEqual(game.homeworlds!.custody!.salusa, advanced ? { normal: 0, elite: canceled ? 1 : 4 } : null);
const otherPlan = f.planActions[1].action;
assert.equal(otherPlan.type, 'battlePlan');
const bounty = player(revealed, f.opponent).leaders.find(l => l.id === otherPlan.leader)!.strength;
const ownerCollection = advanced && game.phase >= 7 && (own.forces[f.location] ?? 0) > 0 ? 1 : 0;
const paidSupport = advanced && caller !== 'owner' ? 1 : 0;
assert.equal(own.spice, player(revealed, f.owner).spice + (caller === 'owner' ? bounty : 0) - paidSupport + ownerCollection,
'Only the sole Traitor winner waives support; later Collection remains separate.');
if (canceled) {
const ownPlan = f.planActions[0].action;
assert.equal(ownPlan.type, 'battlePlan');
assert.equal(own.leaders.find(l => l.id === ownPlan.leader)!.dead, true);
}
assert.equal(game.discard.filter(c => c.id === 'ecaz-harass-withdraw').length, 1, 'After-use disposal also beats own-Traitor winner retention');
assert.ok(!own.hand.some(c => c.id === 'ecaz-harass-withdraw'));
assertHarassHomeworldArrakisCustody(game);
});
}
}
void test('Advanced Salusa low→high return does not retroactively rescore sealed support, and later native shipment uses the real reserves', () => {
const f = createHarassHomeworldArrakisFixture({ advanced: true, outcome: 'winner' });
const revealed = revealHarassHomeworldArrakisBattle(f);
const offer = viewGame(revealed, f.owner).battle!.harassAllocation!;
assert.equal(homeworldSardaukarFreeSupport(revealed, f.owner), false);
assert.ok(casualtyOptions(offer.context.forces, 2, 1).some(c => c.normal === 1 && c.elite === 1));
assert.ok(!casualtyOptions({ ...offer.context.forces, eliteFreeSupport: true }, 2, 1)
.some(c => c.normal === 1 && c.elite === 1), 'Requoting against high Salusa would erase the sealed physical commitment');
const game = finishHarassHomeworldArrakisBattle(revealed, { returns: { [f.location]: { normal: 2, elite: 3 } } });
assert.equal(player(game, f.owner).tanks, 2);
assert.equal(player(game, f.owner).elites!.tanks, 1);
assert.equal(player(game, f.owner).spice, player(revealed, f.owner).spice - 1);
assert.equal(homeworldSardaukarFreeSupport(game, f.owner), true);
const salusa = homeworldPopulations(homeworldContext(game), game.homeworlds!.custody!)
.find(h => h.location === 'homeworld:emperor:salusa')!;
assert.equal(salusa.population, 4);
assert.equal(salusa.side, 'high');
let next = advanceHarassHomeworldArrakis(game,
g => g.turn === 2 && g.phase === 5 && g.active === f.owner && harassHomeworldArrakisClean(g));
assert.ok(viewGame(next, f.owner).homeworldMove, 'The native next movement control remains available');
const before = structuredClone(next);
// The next real Storm can cover the original battle sector. Polar Sink is a
// legal storm-independent destination; the assertion here is native reserve use.
const destination = 'polar_sink:0';
next = applyAction(next, f.owner, { type: 'ship', territory: 'polar_sink',
sector: 0, amount: 1, elite: 1, allyPayment: 0,
homeworldSources: { 'homeworld:emperor:salusa': { normal: 0, elite: 1 } } });
next = advanceHarassHomeworldArrakis(next, harassHomeworldArrakisClean);
assert.equal(player(next, f.owner).reserves, player(before, f.owner).reserves - 1);
assert.equal(player(next, f.owner).elites!.reserves, player(before, f.owner).elites!.reserves - 1);
assert.deepEqual(pool(next, f.owner, 'homeworld:emperor'), pool(before, f.owner, 'homeworld:emperor'));
assert.deepEqual(pool(next, f.owner, 'homeworld:emperor:salusa'), { normal: 0, elite: 3 });
assert.equal(player(next, f.owner).forces[destination], 1);
assert.equal(player(next, f.owner).elites!.forces[destination], 1);
assert.equal(player(next, f.owner).tanks, player(before, f.owner).tanks);
assertHarassHomeworldArrakisCustody(next);
});