Compose classic Homeworld optional modules through original rules

This commit is contained in:
Quintine committed 2026-10-06 18:56:55 +11:00
1 parent 3dd36bd9ff
commit fa025220f8
18 files changed
+1220 -94

No files matched your search

@@ -0,0 +1,326 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
applyAction, initializeDiscoveryGameForAudit, viewGame, type Action, type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { ownedTech } from '../game/tech-tokens';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { forceRevivalQuote } from '../game/revival';
import {
enterHomeworldClassicDiscovery, homeworldClassicDiscoveryInventory,
revealHomeworldClassicDiscovery,
} from './fixture-homeworld-classic-discovery';
import {
advanceHomeworldOptional as advance, claimHomeworldOptional,
createHomeworldOptionalFixture as fixture, createHomeworldOptionalLobby,
finishHomeworldOptionalBattle, initializeHomeworldOptional,
invadeHomeworldOptional, nextHomeworldOptionalStep,
openHomeworldOptionalBattle, optionalHomeworldClean as clean,
optionalHomeworldLeader as leader, optionalHomeworldMovementWindow as movement,
optionalHomeworldPlayer as player, settleHomeworldOptionalArrival, shipHomeworldOptional,
} from './fixture-homeworld-optional-modules';
/** Authorized root rulebook physical pp.21–23: optional modules together or
* separately, original first-Storm token assignment/phase-end collections,
* winner transfer, end-Mentat Stronghold custody, off-planet native reserves and
* combined Imperial shipment. Territory-local effects use the physical original
* Arrakeen/Carthag faces (docs/STRONGHOLD_CARDS.md). All armies, casualties,
* funding, held cards and rewards below originate in original engine actions. */
function reject(game: Game, actor: string, action: Action): void {
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
function legalPolicies(game: Game, actor: string): void {
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, actor); view.players.find(seat => seat.id === actor)!.bot = difficulty;
const actions = botActions(view); assert.ok(actions.length, `${difficulty} has an actual owned continuation.`);
for (const action of actions) homeworldClassicDiscoveryInventory(applyAction(structuredClone(game), actor, action));
}
}
function beforeMovementEnd(state: Game): Game {
let game = state;
for (let i = 0; i < 200; i++) {
assert.equal(game.phase, 5);
const step = nextHomeworldOptionalStep(game);
const after = applyAction(structuredClone(game), step.actor, step.action);
if (after.phase !== 5) return game;
game = after;
}
throw Error('Original Shipping never reached its phase-end action.');
}
function endMovementPhase(game: Game): Game {
const last = nextHomeworldOptionalStep(game), after = applyAction(game, last.actor, last.action);
assert.notEqual(after.phase, 5); return after;
}
function resolveToCleanup(state: Game): Game {
return advance(state, game => game.decision?.kind === 'battleCards');
}
void test('original no-Skills Homeworld Discovery admits Basic/Advanced 2–6 and original optional components only at their real seat/mode boundaries', () => {
for (const advanced of [false, true]) for (const seats of [2, 3, 4, 5, 6] as const) {
const options = { advanced, seats, tech: seats >= 3, strongholds: advanced };
const f = fixture(options);
assert.equal(f.game.players.length, seats);
assert.equal(f.game.phase, 7); assert.equal(f.game.turn, 1);
assert.equal(f.game.leaderSkills, undefined);
homeworldClassicDiscoveryInventory(f.setup); homeworldClassicDiscoveryInventory(f.game);
if (seats >= 3) {
assert.equal(f.beforeFirstStorm.techTokens!.production.owner, f.fremen);
assert.equal(f.beforeFirstStorm.techTokens!.axlotl.owner, null);
assert.equal(f.beforeFirstStorm.techTokens!.heighliners.owner, null);
const assigned = Object.values(f.afterFirstStorm.techTokens!);
assert.ok(assigned.every(token => token.owner));
assert.equal(new Set(assigned.map(token => token.owner)).size, 3);
assert.ok(assigned.every(token => token.spice === 0));
}
if (advanced) {
const claim = claimHomeworldOptional(f.game);
assert.equal(claim.before.strongholdCards!.claimedTurn, 0);
assert.ok(Object.values(claim.before.strongholdCards!.owners).every(owner => owner === null));
assert.equal(claim.after.strongholdCards!.claimedTurn, 1);
if (seats >= 4) assert.equal(claim.after.strongholdCards!.owners.arrakeen, 'atreides');
if (seats >= 5) assert.equal(claim.after.strongholdCards!.owners.carthag, 'harkonnen');
assert.equal(claim.after.strongholdCards!.owners.hidden_mobile_stronghold, null);
homeworldClassicDiscoveryInventory(claim.after);
}
}
assert.throws(() => initializeHomeworldOptional({ seats: 2, tech: true }));
assert.throws(() => initializeHomeworldOptional({ advanced: false, strongholds: true }));
// Invalid ORIGINAL component admission is checked by the actual initializer,
// not by mirroring the profile predicate in an expectation.
const original = createHomeworldOptionalLobby({ tech: true, strongholds: true });
for (const corrupt of [
(game: Game) => { game.techTokens!.axlotl.owner = game.host; },
(game: Game) => { game.techTokens!.heighliners.spice = 1; },
(game: Game) => { game.strongholdCards!.owners.arrakeen = game.host; },
(game: Game) => { game.strongholdCards!.claimedTurn = 1; },
]) {
let changed = structuredClone(original); corrupt(changed);
for (const seat of changed.players) changed = applyAction(changed, seat.id, { type: 'ready' });
assert.throws(() => initializeDiscoveryGameForAudit(changed));
}
});
void test('combined Imperial off-planet shipment and another actual invoice accrue one Heighliner payment, collected once at original Shipping end', () => {
const f = fixture({ tech: true, strongholds: true });
let game = advance(revealHomeworldClassicDiscovery(f), g => g.turn === 2 && g.phase === 5 && clean(g));
const owner = game.techTokens!.heighliners.owner!;
const remaining = new Set([f.collector, 'harkonnen']);
while (remaining.size) {
game = advance(game, g => g.turn === 2 && g.phase === 5 && clean(g) && remaining.has(g.active!));
const actor = game.active!;
if (actor === f.collector) {
const sources = { 'homeworld:emperor': { normal: 1, elite: 0 }, 'homeworld:emperor:salusa': { normal: 1, elite: 0 } };
const ownBefore = structuredClone(player(game, actor));
const homesBefore = structuredClone(viewGame(game, actor).homeworlds!.worlds!);
const target = homesBefore.find(home => home.native === f.fremen)!;
const choice = homeworldShipmentChoice(viewGame(game, actor), target.id, sources); assert.ok(choice.action);
legalPolicies(game, actor);
game = settleHomeworldOptionalArrival(applyAction(game, actor, choice.action));
assert.equal(player(game, actor).spice, ownBefore.spice - 2);
assert.equal(player(game, actor).reserves, ownBefore.reserves - 2);
const homesAfter = viewGame(game, actor).homeworlds!.worlds!;
for (const id of Object.keys(sources)) {
assert.equal(homesAfter.find(home => home.id === id)!.forces[actor].normal,
homesBefore.find(home => home.id === id)!.forces[actor].normal - 1);
}
assert.deepEqual(homesAfter.find(home => home.id === target.id)!.forces[actor], { normal: 2, elite: 0 });
} else {
game = shipHomeworldOptional(game, actor, 'cistern', 0, 1);
}
assert.equal(game.techTokens!.heighliners.spice, 1, 'Two real paid invoices still accrue one industry event.');
assert.equal(game.techTokens!.heighliners.triggeredTurn, 2);
remaining.delete(actor);
if (remaining.size) game = applyAction(game, actor, { type: 'endMovement' });
}
const last = beforeMovementEnd(game), balance = player(last, owner).spice;
const paid = endMovementPhase(last);
assert.equal(player(paid, owner).spice, balance + 1);
assert.equal(paid.techTokens!.heighliners.spice, 0);
assert.equal(paid.techTokens!.axlotl.spice, 0); assert.equal(paid.techTokens!.production.spice, 0);
homeworldClassicDiscoveryInventory(paid);
});
void test('Guild-only actual Homeworld transport is excluded, while a combined Imperial nested shipment pays the printed single tariff and triggers Heighliners', () => {
const f = fixture({ tech: true, strongholds: true });
let guild = movement(f.game, 'guild');
const guildBefore = player(guild, 'guild').spice;
guild = invadeHomeworldOptional(guild, 'guild', 'homeworld:emperor', 1);
assert.equal(player(guild, 'guild').spice, guildBefore - 1);
assert.equal(guild.techTokens!.heighliners.spice, 0);
assert.equal(guild.techTokens!.heighliners.triggeredTurn, 1);
const end = beforeMovementEnd(guild), owner = end.techTokens!.heighliners.owner!, balance = player(end, owner).spice;
const excluded = endMovementPhase(end);
assert.equal(player(excluded, owner).spice, balance);
assert.equal(excluded.techTokens!.heighliners.spice, 0);
let nested = movement(enterHomeworldClassicDiscovery(f), f.collector);
const before = structuredClone(player(nested, f.collector));
nested = shipHomeworldOptional(nested, f.collector, 'cistern', 0, 2, 0,
{ 'homeworld:emperor': { normal: 1, elite: 0 }, 'homeworld:emperor:salusa': { normal: 1, elite: 0 } });
assert.equal(player(nested, f.collector).spice, before.spice - 2);
assert.equal(player(nested, f.collector).forces['cistern:0'], before.forces['cistern:0'] + 2);
assert.equal(nested.techTokens!.heighliners.spice, 1);
homeworldClassicDiscoveryInventory(nested);
});
void test('real low-Caladan poverty claim and three free force returns trigger distinct phase-end industries without charging or recursively paying', () => {
for (const advanced of [false, true]) {
const setup = initializeHomeworldOptional({ advanced, tech: true, strongholds: advanced });
let game = advance(setup, game => game.status === 'playing');
// Select only original unplayed ordinary blows, avoiding incidental income
// at the future expedition site. No phase, wallet or force is assigned.
for (let position = 0; position < 4; position++) {
const index = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card &&
!card.discovery && card.territory !== 'imperial_basin');
assert.ok(index >= position); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
game = movement(game, 'emperor', 1);
game = shipHomeworldOptional(game, 'emperor', 'habbanya_ridge_sietch', 17, 1);
game = movement(game, 'atreides', 1);
game = shipHomeworldOptional(game, 'atreides', 'habbanya_ridge_sietch', 17, 9);
game = applyAction(game, 'atreides', { type: 'move', from: 'arrakeen:10', territory: 'imperial_basin', sector: 10, amount: 10 });
assert.equal(player(game, 'atreides').spice, 1);
assert.equal(player(game, 'atreides').reserves, 1);
game = openHomeworldOptionalBattle(game, 'habbanya_ridge_sietch', 'atreides', 'emperor');
game = applyAction(game, 'atreides', { type: 'battlePlan', leader: leader(game, 'atreides', false), dial: 0, support: 0 });
game = applyAction(game, 'emperor', { type: 'battlePlan', leader: leader(game, 'emperor', true), dial: 0, support: 0 });
game = finishHomeworldOptionalBattle(game);
assert.equal(player(game, 'atreides').tanks, 9);
game = advance(game, game => game.turn === 2 && game.phase === 2 && clean(game));
assert.equal(player(game, 'atreides').spice, 1);
assert.equal(viewGame(game, 'atreides').charity.amount, 2);
const productionOwner = game.techTokens!.production.owner!, prodBefore = player(game, productionOwner).spice;
game = applyAction(game, 'atreides', { type: 'charity' });
assert.equal(player(game, 'atreides').spice, 3);
assert.equal(player(game, productionOwner).spice, prodBefore);
assert.equal(game.techTokens!.production.spice, ownedTech(game.techTokens, productionOwner).length);
reject(game, 'atreides', { type: 'charity' });
const beforeCharityEnd = advance(game, game => game.phase === 2 && clean(game) &&
game.ready.length === game.players.length - 1);
const productionIncome = beforeCharityEnd.techTokens!.production.spice;
const productionBalance = player(beforeCharityEnd, productionOwner).spice;
const afterCharity = advance(beforeCharityEnd, game => game.phase === 3 && clean(game));
assert.equal(player(afterCharity, productionOwner).spice, productionBalance + productionIncome);
assert.equal(afterCharity.techTokens!.production.spice, 0);
game = advance(afterCharity, game => game.phase === 4 && clean(game));
const returning = structuredClone(player(game, 'atreides'));
assert.equal(forceRevivalQuote(game, returning, 3).cost, 0, 'Low home adds the third free counter to Atreides printed two.');
legalPolicies(game, 'atreides');
game = applyAction(game, 'atreides', { type: 'revive', amount: 3, elite: 0 });
game = advance(game, game => !game.pendingRevival && clean(game));
assert.equal(player(game, 'atreides').spice, returning.spice);
assert.equal(player(game, 'atreides').tanks, returning.tanks - 3);
assert.equal(player(game, 'atreides').reserves, returning.reserves + 3);
const axlotlOwner = game.techTokens!.axlotl.owner!, accrued = game.techTokens!.axlotl.spice;
assert.equal(accrued, ownedTech(game.techTokens, axlotlOwner).length);
assert.equal(game.techTokens!.production.spice, 0);
const beforeRevivalEnd = advance(game, game => game.phase === 4 && clean(game) && game.ready.length === game.players.length - 1);
const axlotlBalance = player(beforeRevivalEnd, axlotlOwner).spice;
const afterRevival = advance(beforeRevivalEnd, game => game.phase === 5 && clean(game));
assert.equal(player(afterRevival, axlotlOwner).spice, axlotlBalance + accrued);
assert.equal(afterRevival.techTokens!.axlotl.spice, 0);
assert.equal(afterRevival.techTokens!.heighliners.spice, 0);
homeworldClassicDiscoveryInventory(afterRevival);
}
});
void test('actually held Arrakeen subsidy and Carthag Shield apply at their printed Arrakis sites, never at native Homeworld or Cistern battles', () => {
for (const kind of ['support', 'defense'] as const) for (const location of ['printed', 'homeworld', 'nested'] as const) {
const f = fixture({ tech: true, strongholds: true });
const owner = kind === 'support' ? 'atreides' : 'harkonnen';
const printed = kind === 'support' ? 'arrakeen' : 'carthag';
const territory = location === 'printed' ? printed : location === 'homeworld' ? `homeworld:${owner}` : 'cistern';
let game = location === 'nested' ? enterHomeworldClassicDiscovery(f) : f.game;
assert.equal(game.strongholdCards?.owners[printed], location === 'nested' ? owner : null);
if (location === 'nested') {
game = movement(game, owner);
game = shipHomeworldOptional(game, owner, territory, 0, kind === 'support' ? 3 : 2);
} else {
game = movement(game, f.collector);
assert.equal(game.strongholdCards!.owners[printed], owner);
game = location === 'homeworld' ? invadeHomeworldOptional(game, f.collector, territory, 1)
: shipHomeworldOptional(game, f.collector, territory, printed === 'arrakeen' ? 10 : 11, 1);
}
game = openHomeworldOptionalBattle(game, territory, owner, f.collector);
const view = viewGame(game, owner), ownBefore = structuredClone(player(game, owner));
assert.equal(view.battle!.strongholdEffects[owner], location === 'printed' ? printed : null);
const trainer = leader(game, owner, true), invader = leader(game, f.collector, kind === 'defense');
const defense = player(game, owner).hand.find(card => card.kind === 'shield')!; assert.ok(defense);
const weapon = player(game, f.collector).hand.find(card => card.kind === 'poison')!; assert.ok(weapon);
legalPolicies(game, owner);
game = applyAction(game, owner, { type: 'battlePlan', leader: trainer, defense: defense.id,
dial: kind === 'support' ? 2 : 0, support: kind === 'support' ? 2 : 0 });
game = applyAction(game, f.collector, { type: 'battlePlan', leader: invader, dial: 0, support: 0,
...(kind === 'defense' ? { weapon: weapon.id } : {}) });
game = resolveToCleanup(game);
assert.equal(game.lastBattleContext!.territory, territory);
if (kind === 'support') {
assert.equal(player(game, owner).spice, ownBefore.spice - (location === 'printed' ? 0 : 2));
assert.equal(game.lastBattleContext!.winner, owner);
} else {
assert.equal(player(game, owner).leaders.find(disc => disc.id === trainer)!.dead, location !== 'printed');
assert.equal(game.lastBattleContext!.winner, location === 'printed' ? owner : f.collector);
assert.equal(game.discard.some(card => card.id === defense.id), location !== 'printed');
}
assert.equal(game.strongholdCards!.owners[printed], owner, 'Combat never silently transfers physical Stronghold card custody.');
homeworldClassicDiscoveryInventory(game);
}
});
void test('real native Homeworld victory earns a second token, then real visitor victory offers original winner choice only after physical cleanup', () => {
for (const advanced of [false, true]) {
const f = fixture({ advanced, tech: true, strongholds: advanced });
let game = movement(f.game, f.collector);
game = invadeHomeworldOptional(game, f.collector, 'homeworld:fremen', 1);
game = openHomeworldOptionalBattle(game, 'homeworld:fremen', f.collector, f.fremen);
assert.equal(ownedTech(game.techTokens, f.collector).length, 1);
const nativeBefore = player(game, f.fremen).reserves;
game = applyAction(game, f.collector, { type: 'battlePlan', leader: leader(game, f.collector, false), dial: 0, support: 0 });
game = applyAction(game, f.fremen, { type: 'battlePlan', leader: leader(game, f.fremen, true), dial: 0, support: 0 });
game = finishHomeworldOptionalBattle(game);
assert.equal(game.lastBattleContext!.winner, f.fremen);
assert.equal(player(game, f.fremen).reserves, nativeBefore);
assert.equal(player(game, f.collector).tanks, 1);
assert.equal(ownedTech(game.techTokens, f.fremen).length, 2);
game = movement(game, f.collector, 3);
game = invadeHomeworldOptional(game, f.collector, 'homeworld:fremen', 2);
game = openHomeworldOptionalBattle(game, 'homeworld:fremen', f.collector, f.fremen);
const tokens = ownedTech(game.techTokens, f.fremen), originalTech = structuredClone(game.techTokens);
const visitorBefore = structuredClone(player(game, f.collector));
const nativeBeforeSecond = structuredClone(player(game, f.fremen));
const weapon = visitorBefore.hand.find(card => card.kind === 'poison')!; assert.ok(weapon);
game = applyAction(game, f.collector, { type: 'battlePlan', leader: leader(game, f.collector, true), dial: 1,
support: advanced ? 1 : 0, weapon: weapon.id });
game = applyAction(game, f.fremen, { type: 'battlePlan', leader: leader(game, f.fremen, false), dial: 0, support: 0 });
const cleanup = resolveToCleanup(game);
assert.equal(cleanup.lastBattleContext!.winner, f.collector);
assert.deepEqual(cleanup.techTokens, originalTech);
assert.equal(cleanup.decision!.player, f.collector);
assert.equal(player(cleanup, f.collector).tanks, visitorBefore.tanks + 1);
assert.equal(player(cleanup, f.fremen).tanks, nativeBeforeSecond.tanks + nativeBeforeSecond.reserves);
game = applyAction(cleanup, f.collector, { type: 'decision', discard: [] });
game = advance(game, game => game.decision?.kind === 'techToken');
assert.ok(game.decision?.kind === 'techToken');
assert.equal(game.decision.player, f.collector);
assert.deepEqual([...game.decision.choices].sort(), [...tokens].sort());
assert.deepEqual(game.techTokens, originalTech);
assert.equal(player(game, f.collector).hand.some(card => card.id === weapon.id), true);
const physicalHome = viewGame(game, f.collector).homeworlds!.worlds!.find(home => home.native === f.fremen)!;
assert.deepEqual(physicalHome.forces[f.collector], { normal: 1, elite: 0 });
assert.deepEqual(physicalHome.forces[f.fremen], { normal: 0, elite: 0 });
legalPolicies(game, f.collector);
for (const token of tokens) {
reject(game, f.fremen, { type: 'decision', token });
const chosen = applyAction(structuredClone(game), f.collector, { type: 'decision', token });
assert.equal(chosen.techTokens![token].owner, f.collector);
assert.equal(ownedTech(chosen.techTokens, f.fremen).length, 1);
assert.equal(chosen.lastBattleContext!.winner, f.collector);
const chosenHome = viewGame(chosen, f.collector).homeworlds!.worlds!.find(home => home.id === physicalHome.id)!;
assert.deepEqual(chosenHome.forces, physicalHome.forces, 'Tech selection changes token custody, never the actual winner army.');
homeworldClassicDiscoveryInventory(chosen);
}
}
});