191 lines
13 KiB
TypeScript
191 lines
13 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 {
|
||
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);
|
||
});
|