256 lines
17 KiB
TypeScript
256 lines
17 KiB
TypeScript
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);
|
||
});
|