163 lines
11 KiB
TypeScript
163 lines
11 KiB
TypeScript
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);
|
||
});
|