Compose classic World cards with phase modules

This commit is contained in:
Quintine committed 2026-10-16 16:19:35 +11:00
1 parent ccdcfe2c4d
commit f5b7ebc8da
18 files changed
+845 -35

No files matched your search

+190
View File
@@ -0,0 +1,190 @@
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);
});