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

+6 -7
View File
@@ -55,13 +55,12 @@ void test('Discovery cannot redeal started components or silently remove another
void test('Discovery Skills cannot silently drop unsupported native families or unrelated modules and previews', () => {
const unsupported = lobby(['ixians', 'ecaz', 'guild'], ['ix', 'ecaz']);
assert.throws(() => initializeLeaderSkillsGameForAudit(unsupported), RuleError);
const overlays = Array.from({ length: 5 }, () => lobby(['guild', 'emperor', 'harkonnen'], []));
overlays[0].homeworlds = { custody: null };
overlays[1] = lobby(['ixians', 'choam', 'guild'], ['ix', 'choam']);
overlays[1].nexusCards = { cards: null, phase: null };
overlays[2].advanced = false; overlays[2].strongholdCards = createStrongholdCards();
overlays[3].mentatQuestionPreview = true;
overlays[4].spiceBankerIncomePreview = true;
const overlays = Array.from({ length: 4 }, () => lobby(['guild', 'emperor', 'harkonnen'], []));
overlays[0] = lobby(['ixians', 'choam', 'guild'], ['ix', 'choam']);
overlays[0].nexusCards = { cards: null, phase: null };
overlays[1].advanced = false; overlays[1].strongholdCards = createStrongholdCards();
overlays[2].mentatQuestionPreview = true;
overlays[3].spiceBankerIncomePreview = true;
for (const game of overlays) assert.throws(() => initializeLeaderSkillsGameForAudit(game), RuleError);
const two = lobby(['guild', 'emperor'], []); two.techTokens = createTechTokens();
assert.throws(() => initializeLeaderSkillsGameForAudit(two), RuleError);
+173
View File
@@ -0,0 +1,173 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeDiscoveryGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game,
} from '../game/engine';
import type { FactionId } from '../game/catalog';
import { treacheryDeck } from '../game/cards';
import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import {
createHomeworldClassicDiscoveryFixture, homeworldClassicDiscoveryClean,
homeworldClassicDiscoveryPlayer, nextHomeworldClassicDiscoveryStep,
type HomeworldClassicDiscoveryFixture,
} from './fixture-homeworld-classic-discovery';
export type HomeworldOptionalStep = { actor: string; action: Action };
export type HomeworldOptionalOptions = {
initial?: Game;
advanced?: boolean;
seats?: 2 | 3 | 4 | 5 | 6;
tech?: boolean;
strongholds?: boolean;
};
export type HomeworldOptionalFixture = HomeworldClassicDiscoveryFixture & {
beforeFirstStorm: Game;
afterFirstStorm: Game;
};
export const optionalHomeworldPlayer = homeworldClassicDiscoveryPlayer;
export const optionalHomeworldClean = homeworldClassicDiscoveryClean;
/** Only original undealt card lotteries are chosen. The first seven random
* draws shuffle the eight opaque Discovery faces; the following shuffle is the
* original 33-card Treachery deck. Starting cards are dealt by native setup. */
function originalStartingLottery<T>(game: Game, initialize: () => T): T {
const cards = treacheryDeck(), remaining = [...cards];
const prefix = game.players.flatMap(player => {
const kinds = player.faction === 'emperor' ? ['poison'] : player.faction === 'harkonnen'
? ['shield', 'worthless'] : player.faction === 'atreides' ? ['shield'] : ['worthless'];
return kinds.map(kind => {
const index = remaining.findIndex(card => card.kind === kind); assert.ok(index >= 0);
return remaining.splice(index, 1)[0];
});
});
const desired = [...prefix, ...remaining], working = [...cards], rolls: number[] = [];
for (let i = working.length - 1; i > 0; i--) {
const j = working.findIndex(card => card.id === desired[i].id); assert.ok(j >= 0 && j <= i);
rolls.push(Math.floor((j + 0.5) / (i + 1) * 0x100000000));
[working[i], working[j]] = [working[j], working[i]];
}
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto); let draw = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) {
if (draw < 7) array[0] = 0xffffffff;
else if (draw < 7 + rolls.length) array[0] = rolls[draw - 7];
else Reflect.apply(original, crypto, [array]);
draw++;
} else Reflect.apply(original, crypto, [array]);
return array;
};
try { return initialize(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Original fresh no-Skills lobby, optional unused physical components, original
* Homeworld/Discovery initializer. No owners, balances, forces or held cards are
* injected; even the optional components are selected by their host controls. */
export function createHomeworldOptionalLobby(options: HomeworldOptionalOptions = {}): Game {
let game = options.initial ? structuredClone(options.initial)
: createGame('HWOPTIONAL', newPlayer('emperor', 'Emperor', 'emperor'), options.advanced ?? true);
if (!options.initial) {
const roster: FactionId[] = ['fremen', 'guild', 'atreides', 'harkonnen', 'beneGesserit'];
for (const faction of roster.slice(0, (options.seats ?? 5) - 1)) joinGame(game, newPlayer(faction, faction, faction));
}
assert.ok(game.status === 'lobby' || (game.status === 'setup' &&
(game.setupStage === 'prediction' || game.setupStage === 'traitors') &&
game.turn === 1 && game.phase === 0 && !game.leaderSkills &&
game.discoveryEnabled && game.discoveries && game.homeworlds?.custody === null),
'Continue only an original fresh lobby or its initial Homeworld Discovery setup.');
if (game.status === 'setup') return game;
if (!game.homeworlds) game = applyAction(game, game.host, { type: 'homeworlds', enabled: true });
if (options.tech && !game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
if (options.strongholds && !game.strongholdCards) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true });
game.discoveryEnabled = true;
return game;
}
export function initializeHomeworldOptional(options: HomeworldOptionalOptions = {}): Game {
let game = createHomeworldOptionalLobby(options);
if (game.status === 'setup') return game;
for (const seat of game.players) if (!seat.ready) game = applyAction(game, seat.id, { type: 'ready' });
return originalStartingLottery(game, () => initializeDiscoveryGameForAudit(game));
}
export function nextHomeworldOptionalStep(game: Game): HomeworldOptionalStep {
if (!game.phaseOpening && !game.response) {
if (game.decision?.kind === 'battleCards') return { actor: game.decision.player, action: { type: 'decision', discard: [] } };
if (game.decision?.kind === 'techToken') return { actor: game.decision.player,
action: { type: 'decision', token: game.decision.choices[0] } };
}
return nextHomeworldClassicDiscoveryStep(game);
}
export function actHomeworldOptional(game: Game, step: HomeworldOptionalStep, actions?: HomeworldOptionalStep[]): Game {
actions?.push(structuredClone(step)); return applyAction(game, step.actor, step.action);
}
export function advanceHomeworldOptional(state: Game, until: (game: Game) => boolean,
actions?: HomeworldOptionalStep[]): Game {
let game = state;
for (let i = 0; !until(game) && i < 1800; i++) {
assert.notEqual(game.status, 'finished');
game = actHomeworldOptional(game, nextHomeworldOptionalStep(game), actions);
}
assert.ok(until(game), 'The original optional Homeworld action window was not reached.');
return game;
}
export function createHomeworldOptionalFixture(options: HomeworldOptionalOptions = {}): HomeworldOptionalFixture {
const setup = initializeHomeworldOptional(options);
const beforeFirstStorm = advanceHomeworldOptional(setup, game => game.status === 'playing' &&
game.phase === 0 && optionalHomeworldClean(game));
const afterFirstStorm = advanceHomeworldOptional(beforeFirstStorm, game => game.phase === 1 && optionalHomeworldClean(game));
const fixture = createHomeworldClassicDiscoveryFixture({ initial: setup, prepareImperialSplit: true });
return { ...fixture, beforeFirstStorm, afterFirstStorm };
}
export function optionalHomeworldMovementWindow(state: Game, actor: string, turn = 2): Game {
return advanceHomeworldOptional(state, game => game.turn === turn && game.phase === 5 &&
game.active === actor && optionalHomeworldClean(game));
}
export function settleHomeworldOptionalArrival(state: Game): Game {
return advanceHomeworldOptional(state, game => !game.pendingShipment && !game.pendingHomeworldShipment && optionalHomeworldClean(game));
}
export function shipHomeworldOptional(game: Game, actor: string, territory: string, sector: number,
amount: number, elite = 0, sources = nativeShipmentSources(viewGame(game, actor), amount, elite)): Game {
assert.ok(sources);
return settleHomeworldOptionalArrival(applyAction(game, actor,
{ type: 'ship', territory, sector, amount, elite, allyPayment: 0, homeworldSources: sources }));
}
export function invadeHomeworldOptional(game: Game, actor: string, destination: string, amount: number): Game {
const native = nativeShipmentSources(viewGame(game, actor), amount, 0); assert.ok(native);
const sources = Object.fromEntries(Object.entries(native).filter(([, group]) => group.normal + group.elite > 0));
const choice = homeworldShipmentChoice(viewGame(game, actor), destination, sources); assert.ok(choice.action, choice.blocked ?? undefined);
return settleHomeworldOptionalArrival(applyAction(game, actor, choice.action));
}
export function openHomeworldOptionalBattle(state: Game, territory: string, first: string, second: string): Game {
let game = advanceHomeworldOptional(state, game => game.phase === 6 && !!game.active && optionalHomeworldClean(game));
const actor = game.active!; assert.ok(actor === first || actor === second);
game = applyAction(game, actor, { type: 'chooseBattle', territory, target: actor === first ? second : first });
return advanceHomeworldOptional(game, game => !!game.battle && !game.battle.preparation && optionalHomeworldClean(game));
}
export function optionalHomeworldLeader(game: Game, actor: string, strongest: boolean): string {
const leader = optionalHomeworldPlayer(game, actor).leaders.filter(disc => !disc.dead &&
(!disc.usedAt || disc.usedAt === game.battle?.territory))
.sort((a, b) => strongest ? b.strength - a.strength : a.strength - b.strength)[0];
assert.ok(leader); return leader.id;
}
export function finishHomeworldOptionalBattle(state: Game): Game {
const territory = state.battle!.territory;
return advanceHomeworldOptional(state, game =>
!game.battle && !game.homeworldBattleLoss && optionalHomeworldClean(game) && game.lastBattleContext?.territory === territory);
}
export type HomeworldOptionalClaim = { before: Game; step: HomeworldOptionalStep; after: Game };
export function claimHomeworldOptional(state: Game): HomeworldOptionalClaim {
let game = advanceHomeworldOptional(state, game => game.phase === 8 && optionalHomeworldClean(game));
const turn = game.turn;
for (let i = 0; i < 100; i++) {
const before = structuredClone(game), step = nextHomeworldOptionalStep(game);
game = actHomeworldOptional(game, step);
if (game.turn !== turn || game.status === 'finished') return { before, step, after: game };
if (game.strongholdCards?.claimedTurn === turn) return { before, step, after: game };
}
throw Error('Original end-Mentat did not settle its Stronghold Cards.');
}
+256
View File
@@ -0,0 +1,256 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
import type { FactionId } from '../game/catalog';
import type { LeaderSkillId } from '../game/leader-skill-cards';
import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_SPICE_CARDS, DISCOVERY_TOKEN_BY_ID,
validateDiscoveryState, type DiscoveryLocationId, type DiscoveryOpaqueTokenId } from '../game/discoveries';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import { nativeShipmentSources } from '../game/homeworld-options';
import type { SukReserveDestinations } from '../game/suk-graduate';
import {
assertClassicHomeworldSkillsCustody, classicHomeworldSkillsPlayer,
createClassicHomeworldSkillsSetup, nextClassicHomeworldSkillsStep,
type ClassicHomeworldSkillsSetup, type ClassicHomeworldSkillsStep,
} from './fixture-homeworld-classic-skills';
export type HomeworldSkillsDiscoveryStep = ClassicHomeworldSkillsStep;
export type HomeworldSkillsDiscoveryOptions = {
initial?: Game;
advanced?: boolean;
seats?: 2 | 3 | 4 | 5 | 6;
roster?: readonly FactionId[];
skill?: LeaderSkillId;
face?: DiscoveryLocationId;
shipForMaker?: boolean;
};
export type HomeworldSkillsDiscoveryFixture = ClassicHomeworldSkillsSetup & {
game: Game;
owner: string;
fremen: string;
opponent: string | null;
trainer: string;
token: DiscoveryOpaqueTokenId;
face: DiscoveryLocationId;
parent: string;
parentSector: number;
wormTerritory: string;
wormSector: number;
amount: number;
elite: number;
beforeShipment: Game;
afterShipment: Game;
};
export type HomeworldSkillsDiscoveryBattleFixture = HomeworldSkillsDiscoveryFixture & {
entered: Game;
beforeReinforcement: Game;
afterReinforcement: Game;
beforeBattle: Game;
plans: HomeworldSkillsDiscoveryStep[];
};
export const homeworldSkillsDiscoveryPlayer = classicHomeworldSkillsPlayer;
export const homeworldSkillsDiscoveryClean = (game: Game) =>
!game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
/** An original fresh lobby, never a second reserve or component model. */
export function createHomeworldSkillsDiscoveryLobby(options: HomeworldSkillsDiscoveryOptions = {}): Game {
if (options.initial) return structuredClone(options.initial);
const factions: readonly FactionId[] = options.roster ?? ['emperor', 'fremen', 'guild', 'harkonnen', 'atreides', 'beneGesserit'];
let game = createGame('HWSKILLSDISCOVERY', newPlayer(factions[0], factions[0], factions[0]), options.advanced ?? false);
for (const faction of factions.slice(1, options.seats ?? (options.roster ? factions.length : 4))) joinGame(game, newPlayer(faction, faction, faction));
game = applyAction(game, game.host, { type: 'homeworlds', enabled: true });
game.discoveryEnabled = true;
return game;
}
export function createHomeworldSkillsDiscoverySetup(options: HomeworldSkillsDiscoveryOptions = {}): ClassicHomeworldSkillsSetup {
return createClassicHomeworldSkillsSetup({ initial: createHomeworldSkillsDiscoveryLobby(options), skill: options.skill ?? 'suk-graduate' });
}
/** Consume only original owner-labelled queues; human plans and rescue choices
* remain explicit boundaries. The lottery helper below never signs a window. */
export function nextHomeworldSkillsDiscoveryStep(game: Game): HomeworldSkillsDiscoveryStep | null {
if (!game.phaseOpening && !game.response) {
const decision = game.decision;
if (decision?.kind === 'discoveryEntry' || decision?.kind === 'greatMakerRide')
return { actor: decision.player, action: { type: 'decision', event: decision.event, accept: false } };
if (decision?.kind === 'greatMakerVote')
return { actor: decision.player, action: { type: 'decision', event: decision.event, yes: false } };
if (decision?.kind === 'wormRide') return { actor: decision.player, action: { type: 'decision', accept: false } };
if (decision?.kind === 'wormProtection') return { actor: decision.player, action: { type: 'decision', accept: true } };
}
return nextClassicHomeworldSkillsStep(game, game.players.find(p => p.faction === 'emperor')?.id);
}
export function advanceHomeworldSkillsDiscovery(state: Game, until: (game: Game) => boolean,
actions?: HomeworldSkillsDiscoveryStep[]): Game {
let game = state;
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const next = nextHomeworldSkillsDiscoveryStep(game);
assert.ok(next, 'A real sealed plan or physical Suk rescue choice is required.');
actions?.push(structuredClone(next)); game = applyAction(game, next.actor, next.action);
}
throw Error('Original Homeworld/Skills/Discovery programme did not reach its owned window.');
}
export function assertHomeworldSkillsDiscoveryInventory(game: Game): void {
assertClassicHomeworldSkillsCustody(game);
validateDiscoveryState(game.discoveries!);
const spice = [...game.spiceDeck, ...game.spiceDiscard.flat(), ...(game.spiceResolution?.skipped ?? []),
...(game.spiceSequence?.skipped ?? [])];
assert.equal(spice.filter(card => 'territory' in card && !!card.discovery).length, 6);
assert.equal(spice.filter(card => 'worm' in card && card.greatMaker).length, 1);
assert.equal(game.discoveries!.tokens.length, 8);
}
function frontUnplayedSpice(game: Game, choose: (card: Game['spiceDeck'][number]) => boolean, position: number): void {
const index = game.spiceDeck.findIndex((card, i) => i >= position && choose(card));
assert.ok(index >= position, 'Select only an original still-unplayed physical Spice Card.');
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
function originalTokenLottery<T>(game: Game, face: DiscoveryLocationId, operation: () => T): T {
const pool = game.discoveries!.tokens.filter(token => token.status === 'supply' && token.type === DISCOVERY_TOKEN_BY_ID[face].type);
const index = pool.findIndex(token => token.face === face); assert.ok(index >= 0);
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'), native = crypto.getRandomValues.bind(crypto);
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1)
array[0] = Math.floor((index + 0.5) / pool.length * 0x100000000);
else Reflect.apply(native, crypto, [array]);
return array;
};
try { return operation(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
function act(game: Game, step: HomeworldSkillsDiscoveryStep, actions: HomeworldSkillsDiscoveryStep[]): Game {
actions.push(structuredClone(step)); return applyAction(game, step.actor, step.action);
}
/** PDF21–23 variants together: original all14 training before faction setup,
* original seven Spice Cards/eight tokens, and original Homeworld withdrawals.
* Only undealt lotteries/unplayed Spice order are selected; no force staging. */
export function createHomeworldSkillsDiscoveryFixture(options: HomeworldSkillsDiscoveryOptions = {}): HomeworldSkillsDiscoveryFixture {
const setup = createHomeworldSkillsDiscoverySetup(options);
let game = structuredClone(setup.afterSetup);
const owner = game.players.find(p => p.faction === 'emperor')!.id;
const fremen = game.players.find(p => p.faction === 'fremen')!.id;
const opponent = game.players.find(p => p.faction === 'guild')?.id ?? null;
const trainer = game.leaderSkills!.assignments.find(a => a.owner === owner)!.leader;
const face = options.face ?? 'cistern';
frontUnplayedSpice(game, card => 'territory' in card && card.discovery === 'discovery-hagga-basin', 0);
if (game.advanced) frontUnplayedSpice(game, card => 'territory' in card && !card.discovery && card.territory !== 'hagga_basin', 1);
const nextTurnIndex = game.advanced ? 2 : 1;
frontUnplayedSpice(game, card => 'worm' in card && !!card.greatMaker, nextTurnIndex);
for (let position = nextTurnIndex + 1; position < nextTurnIndex + (game.advanced ? 3 : 2); position++)
frontUnplayedSpice(game, card => 'territory' in card && !card.discovery && card.territory !== 'hagga_basin', position);
setup.staging.push('Selected only the conserved unplayed Hagga Discovery/next-turn Great Maker Spice order and the original Hiereg supply lottery; every force arrives through an engine action.');
game = advanceHomeworldSkillsDiscovery(game, g => g.phase === 1 && homeworldSkillsDiscoveryClean(g), setup.actions);
game = originalTokenLottery(game, face, () => advanceHomeworldSkillsDiscovery(game,
g => g.discoveries!.tokens.some(t => t.face === face && t.status === 'placed'), setup.actions));
game = advanceHomeworldSkillsDiscovery(game, g => g.phase === 5 && g.active === owner && homeworldSkillsDiscoveryClean(g), setup.actions);
const placement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
const blow = DISCOVERY_SPICE_CARDS.find(card => card.discovery === 'discovery-hagga-basin')!;
const destination = options.shipForMaker ? blow : placement;
const amount = game.advanced ? 3 : 2, elite = game.advanced ? 1 : 0;
const beforeShipment = structuredClone(game), sources = nativeShipmentSources(viewGame(game, owner), amount, elite); assert.ok(sources);
game = act(game, { actor: owner, action: { type: 'ship', territory: destination.territory,
sector: destination.sector, amount, elite, allyPayment: 0, homeworldSources: sources } }, setup.actions);
game = advanceHomeworldSkillsDiscovery(game, g => !g.pendingShipment && homeworldSkillsDiscoveryClean(g), setup.actions);
const afterShipment = structuredClone(game);
game = advanceHomeworldSkillsDiscovery(game, g => g.phase === 7 && homeworldSkillsDiscoveryClean(g), setup.actions);
const token = game.discoveries!.tokens.find(t => t.face === face)!;
assert.equal(game.turn, 1); assertHomeworldSkillsDiscoveryInventory(game);
return { ...setup, game, owner, fremen, opponent, trainer, token: token.id, face,
parent: placement.territory, parentSector: placement.sector, wormTerritory: blow.territory,
wormSector: blow.sector, amount, elite, beforeShipment, afterShipment };
}
export function revealHomeworldSkillsDiscovery(fixture: HomeworldSkillsDiscoveryFixture): Game {
let game = structuredClone(fixture.game);
if (viewGame(game, fixture.owner).discoveries!.canInspect.includes(fixture.token))
game = applyAction(game, fixture.owner, { type: 'discovery', token: fixture.token, reveal: false });
assert.ok(viewGame(game, fixture.owner).discoveries!.canReveal.includes(fixture.token));
return applyAction(game, fixture.owner, { type: 'discovery', token: fixture.token, reveal: true });
}
export function homeworldSkillsDiscoveryEntryWindow(fixture: HomeworldSkillsDiscoveryFixture): Game {
return advanceHomeworldSkillsDiscovery(revealHomeworldSkillsDiscovery(fixture), game =>
game.turn === 2 && game.decision?.kind === 'discoveryEntry' && game.decision.player === fixture.owner);
}
export function enterHomeworldSkillsDiscovery(fixture: HomeworldSkillsDiscoveryFixture): Game {
const game = homeworldSkillsDiscoveryEntryWindow(fixture), view = viewGame(game, fixture.owner);
const action = discoveryEntryMoveAction(view, view.discoveryEntry!.sources); assert.ok(action);
return applyAction(game, fixture.owner, action);
}
export function homeworldSkillsDiscoveryMakerWindow(fixture: HomeworldSkillsDiscoveryFixture): Game {
return advanceHomeworldSkillsDiscovery(structuredClone(fixture.game), game => game.decision?.kind === 'greatMakerVote');
}
/** Genuine reveal -> next-turn parent entry -> paid reinforcement and enemy
* shipment -> nested battle. Neither physical army is placed by the fixture. */
export function createHomeworldSkillsDiscoveryBattleFixture(options: HomeworldSkillsDiscoveryOptions = {}): HomeworldSkillsDiscoveryBattleFixture {
const fixture = createHomeworldSkillsDiscoveryFixture(options);
assert.ok(fixture.opponent, 'The connected nested battle needs the original Guild seat.');
let game = enterHomeworldSkillsDiscovery(fixture);
const entered = structuredClone(game), reinforcement = game.advanced ? 2 : 3;
const beforeByActor = new Map<string, Game>(), afterByActor = new Map<string, Game>();
// Original storm order determines which real shipment happens first.
game = advanceHomeworldSkillsDiscovery(game, g => g.turn === 2 && g.phase === 5 && homeworldSkillsDiscoveryClean(g), fixture.actions);
const remaining = new Set([fixture.owner, fixture.opponent]);
while (remaining.size) {
game = advanceHomeworldSkillsDiscovery(game, g => g.turn === 2 && g.phase === 5 &&
homeworldSkillsDiscoveryClean(g) && remaining.has(g.active!), fixture.actions);
const actor = game.active!, amount = actor === fixture.owner ? reinforcement : 2;
beforeByActor.set(actor, structuredClone(game));
const sources = nativeShipmentSources(viewGame(game, actor), amount, 0); assert.ok(sources);
game = act(game, { actor, action: { type: 'ship', territory: fixture.face, sector: 0,
amount, elite: 0, allyPayment: 0, homeworldSources: sources } }, fixture.actions);
game = advanceHomeworldSkillsDiscovery(game, g => !g.pendingShipment && homeworldSkillsDiscoveryClean(g), fixture.actions);
afterByActor.set(actor, structuredClone(game)); remaining.delete(actor);
game = act(game, { actor, action: { type: 'endMovement' } }, fixture.actions);
}
game = advanceHomeworldSkillsDiscovery(game, g => g.phase === 6 && homeworldSkillsDiscoveryClean(g), fixture.actions);
const beforeBattle = structuredClone(game), actor = game.active!;
assert.ok(actor === fixture.owner || actor === fixture.opponent);
game = act(game, { actor, action: { type: 'chooseBattle', territory: fixture.face,
target: actor === fixture.owner ? fixture.opponent : fixture.owner } }, fixture.actions);
game = advanceHomeworldSkillsDiscovery(game, g => !!g.battle && homeworldSkillsDiscoveryClean(g) &&
!g.battle.preparation && g.battle.preLeader?.closed !== false, fixture.actions);
const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
const enemy = classicHomeworldSkillsPlayer(game, fixture.opponent);
const leader = enemy.leaders.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => a.strength - b.strength)[0]; assert.ok(leader);
const plans: HomeworldSkillsDiscoveryStep[] = [
{ actor: fixture.owner, action: { type: 'battlePlan', leader: fixture.trainer, dial: 4, support: game.advanced ? 2 : 0 } },
{ actor: fixture.opponent, action: { type: 'battlePlan', leader: leader.id, dial: 0 } },
];
assertHomeworldSkillsDiscoveryInventory(game);
return { ...fixture, game, entered, beforeReinforcement: beforeByActor.get(fixture.owner)!,
afterReinforcement: afterByActor.get(fixture.owner)!, beforeBattle, plans };
}
export function revealHomeworldSkillsDiscoveryBattle(fixture: HomeworldSkillsDiscoveryBattleFixture): Game {
let game = structuredClone(fixture.game);
for (const step of fixture.plans) {
game = applyAction(game, step.actor, step.action);
if (!game.battle?.revealed) game = advanceHomeworldSkillsDiscovery(game,
g => homeworldSkillsDiscoveryClean(g) && !!g.battle && !g.battle.preparation);
}
assert.ok(game.battle?.revealed); return game;
}
export function settleHomeworldSkillsDiscoveryBattle(state: Game, stop: 'suk' | 'complete' = 'complete'): Game {
return advanceHomeworldSkillsDiscovery(state, g => stop === 'suk' && g.decision?.kind === 'sukRescue' ||
!g.battle && homeworldSkillsDiscoveryClean(g));
}
export function homeworldSkillsDiscoveryRescueChoice(game: Game, kept: 'normal' | 'elite' = 'normal'): number {
const decision = game.decision; assert.ok(decision?.kind === 'sukRescue');
const maximum = Math.max(...decision.options.map(o => o.normal + o.elite));
const choice = decision.options.findIndex(o => o.normal + o.elite === maximum && o.kept?.kind === kept);
assert.ok(choice >= 0); return choice;
}
export function homeworldSkillsDiscoverySplitReturn(game: Game, choice: number): SukReserveDestinations | undefined {
const decision = game.decision; assert.ok(decision?.kind === 'sukRescue');
if (!decision.reserveHomes) return undefined;
const option = decision.options[choice], normal = option.normal - (option.kept?.kind === 'normal' ? 1 : 0),
elite = option.elite - (option.kept?.kind === 'elite' ? 1 : 0);
if (!normal && !elite) return undefined;
// Explicit prototype placement interpretation, not printed revival placement:
// return ordinary pieces to Salusa and Sardaukar to Kaitain, without moving a native saved counter.
return Object.fromEntries(decision.reserveHomes.map(home => [home.id,
home.secondary ? { normal, elite: 0 } : { normal: 0, elite }]));
}
@@ -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);
}
}
});
@@ -0,0 +1,247 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { DIFFICULTIES } from '../game/bot-profiles';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { gameTerritories, validGameLocation } from '../game/board';
import { discoveryEntryDeclineAction, discoveryEntryMoveAction } from '../game/discovery-entry-options';
import { greatMakerRideAction } from '../game/great-maker-options';
import { homeworldContext } from '../game/homeworld-game';
import { homeworldForceGroups } from '../game/homeworld-custody';
import type { HomeworldCombatLossContext } from '../game/homeworld-combat-loss';
import { quoteSukPhysicalRescue } from '../game/suk-graduate';
import {
classicHomeworldSkillsBotOffers, createClassicHomeworldSkillsFixture,
quoteClassicHomeworldSkillsBattle, revealClassicHomeworldSkillsBattle,
settleClassicHomeworldSkillsBattle,
} from './fixture-homeworld-classic-skills';
import {
advanceHomeworldSkillsDiscovery as advance, assertHomeworldSkillsDiscoveryInventory as inventory,
createHomeworldSkillsDiscoveryBattleFixture as battleFixture, createHomeworldSkillsDiscoveryFixture as fixture,
createHomeworldSkillsDiscoveryLobby as lobby, createHomeworldSkillsDiscoverySetup as setup,
homeworldSkillsDiscoveryClean as clean, homeworldSkillsDiscoveryEntryWindow as entryWindow,
homeworldSkillsDiscoveryMakerWindow as makerWindow, homeworldSkillsDiscoveryPlayer as player,
homeworldSkillsDiscoveryRescueChoice as rescueChoice, homeworldSkillsDiscoverySplitReturn as splitReturn,
revealHomeworldSkillsDiscovery as reveal, revealHomeworldSkillsDiscoveryBattle as revealBattle,
settleHomeworldSkillsDiscoveryBattle as settle,
} from './fixture-homeworld-skills-discovery';
/** Authorized root revision physical PDF21–23: original variants may compose.
* Programmes select original undealt offers and unplayed Spice/token lotteries;
* actions alone establish native withdrawals, casualties, reveal, entry and wins.
* External Emperor split return remains the documented prototype placement
* interpretation, not the PDF22 revival rule or a publisher allocation ruling. */
function legalPolicies(game: Game, actor: string): void {
for (const difficulty of DIFFICULTIES) {
const offers = classicHomeworldSkillsBotOffers(game, actor, difficulty);
assert.ok(offers.length, `${difficulty} needs an owned original continuation.`);
inventory(applyAction(structuredClone(game), actor, offers[0]));
}
}
function reject(game: Game, actor: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
}
function physicalContext(game: Game): HomeworldCombatLossContext {
return { advanced: game.advanced, players: game.players.map(p => {
const total = Object.values(p.forces).reduce((a, b) => a + b, 0);
const elite = Object.values(p.elites?.forces ?? {}).reduce((a, b) => a + b, 0);
return { id: p.id, faction: p.faction, reserves: p.reserves, eliteReserves: p.elites?.reserves ?? 0,
tanks: p.tanks, eliteTanks: p.elites?.tanks ?? 0, battleLosses: p.battleLosses,
boardForces: { normal: total - elite, elite } };
}) };
}
void test('original combined Basic/Advanced two-through-six setup deals all14 before faction choices and preserves DS7/8 and original hands', () => {
for (const advanced of [false, true]) for (const seats of [2, 3, 4, 5, 6] as const) {
const f = setup({ advanced, seats }), physical = f.offered.leaderSkills!;
assert.equal(f.afterSetup.players.length, seats);
assert.deepEqual([...physical.deck, ...Object.values(physical.offers).flatMap(o => o.cards),
...physical.assignments.map(a => a.skill)].sort(), LEADER_SKILL_CARDS.map(c => c.id).sort());
assert.equal(f.offered.setupStage, 'leaderSkills');
assert.equal(f.offered.players.flatMap(p => p.traitors).length, 0);
assert.equal(f.afterSetup.leaderSkills!.assignments.length, seats);
for (const p of f.afterSetup.players) {
assert.equal(p.hand.length, p.faction === 'harkonnen' ? 2 : 1);
assert.ok(f.afterSetup.leaderSkills!.assignments.some(a => a.owner === p.id && p.leaders.some(l => l.id === a.leader && !l.dead)));
}
for (const p of f.offered.players) legalPolicies(f.offered, p.id);
inventory(f.offered); inventory(f.afterSetup);
}
});
void test('all fourteen original skills remain physically selectable through combined Emperor offers in both rules modes', () => {
for (const advanced of [false, true]) for (const card of LEADER_SKILL_CARDS) {
const f = setup({ advanced, seats: 2, skill: card.id });
assert.equal(f.afterSetup.leaderSkills!.assignments.find(a => a.owner === 'emperor')!.skill, card.id);
legalPolicies(f.offered, 'emperor'); inventory(f.afterSetup);
}
});
void test('real Maker losses precede actual storm-order majority or tie votes and a free typed native-reserve ride without losing training', () => {
for (const advanced of [false, true]) for (const majority of [false, true]) {
const f = fixture({ advanced, shipForMaker: true });
const beforeArmy = player(f.game, f.owner), key = `${f.wormTerritory}:${f.wormSector}`;
assert.equal(beforeArmy.forces[key], f.amount);
assert.equal(beforeArmy.elites?.forces[key] ?? 0, f.elite);
const originalTraining = structuredClone(f.game.leaderSkills!.assignments);
const originalHomes = structuredClone(f.game.homeworlds!.custody);
let game = makerWindow(f);
const devoured = player(game, f.owner);
assert.equal(game.turn, 2); assert.equal(game.phase, 1);
assert.equal(game.greatMaker!.territory, f.wormTerritory);
assert.equal(devoured.forces[key] ?? 0, 0);
assert.equal(devoured.tanks, beforeArmy.tanks + f.amount);
assert.equal(devoured.elites?.tanks ?? 0, (beforeArmy.elites?.tanks ?? 0) + f.elite);
assert.equal(devoured.reserves, beforeArmy.reserves);
assert.deepEqual(game.homeworlds!.custody, originalHomes);
assert.deepEqual(game.leaderSkills!.assignments, originalTraining);
assert.equal(game.discoveries!.tokens.find(t => t.id === f.token)!.revealedTurn, null);
const order = [...game.order];
for (const [index, actor] of order.entries()) {
assert.ok(game.decision?.kind === 'greatMakerVote'); assert.equal(game.decision.player, actor);
legalPolicies(game, actor);
const vote: Action = { type: 'decision', event: game.decision.event, yes: index < (majority ? 3 : 2) };
reject(game, order[(index + 1) % order.length], vote);
game = applyAction(game, actor, vote);
}
assert.equal(game.nexus, majority, 'Three of four opens Nexus; two of four does not.');
assert.ok(game.decision?.kind === 'greatMakerRide'); assert.equal(game.decision.player, f.fremen);
legalPolicies(game, f.fremen);
const view = viewGame(game, f.fremen), target = view.greatMaker!.ride!.destinations.find(d => d.territory === 'polar_sink'); assert.ok(target);
const before = structuredClone(player(game, f.fremen)), training = structuredClone(game.leaderSkills!.assignments);
const elite = advanced ? 1 : 0, action = greatMakerRideAction(view, target.territory, target.sector, 2, elite); assert.ok(action);
reject(game, f.fremen, { ...action, event: 'stale-maker-ride' });
reject(game, f.fremen, { ...action, territory: 'homeworld:fremen', sector: 0 });
reject(game, f.fremen, { ...action, elite: 4 });
game = applyAction(game, f.fremen, action);
const after = player(game, f.fremen), destination = `${target.territory}:${target.sector}`;
assert.equal(after.reserves, before.reserves - 2);
assert.equal(after.elites?.reserves ?? 0, (before.elites?.reserves ?? 0) - elite);
assert.equal(after.forces[destination], (before.forces[destination] ?? 0) + 2);
assert.equal(after.elites?.forces[destination] ?? 0, (before.elites?.forces[destination] ?? 0) + elite);
assert.equal(after.spice, before.spice); assert.equal(after.moved, before.moved); assert.equal(after.shipped, before.shipped);
const native = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!).find(h => h.native === f.fremen)!;
assert.deepEqual(native.forces[f.fremen], { normal: after.reserves - (after.elites?.reserves ?? 0), elite: after.elites?.reserves ?? 0 });
assert.deepEqual(game.leaderSkills!.assignments, training); inventory(game);
}
});
void test('actual inspection/reveal and later entry move only real parent forces, retaining native custody, training and normal movement allowances', () => {
for (const advanced of [false, true]) {
const f = fixture({ advanced }), revealed = reveal(f);
assert.equal(validGameLocation(f.game, f.face, 0), false);
assert.equal(validGameLocation(revealed, f.face, 0), true);
assert.equal(revealed.discoveries!.tokens.find(t => t.id === f.token)!.revealedTurn, 1);
assert.equal(player(revealed, f.owner).forces[`${f.face}:0`] ?? 0, 0, 'Reveal itself is not physical entry.');
const game = entryWindow(f), view = viewGame(game, f.owner), sources = view.discoveryEntry!.sources;
assert.equal(game.turn, 2); assert.equal(game.phase, 0);
assert.deepEqual(sources, [{ source: `${f.parent}:${f.parentSector}`, normal: f.amount - f.elite, elite: f.elite }]);
legalPolicies(game, f.owner);
const action = discoveryEntryMoveAction(view, sources), decline = discoveryEntryDeclineAction(view); assert.ok(action && decline);
const before = structuredClone(player(game, f.owner)), custody = structuredClone(game.homeworlds!.custody), training = structuredClone(game.leaderSkills!.assignments);
assert.deepEqual(player(applyAction(structuredClone(game), f.owner, decline), f.owner).forces, before.forces);
reject(game, f.owner, { ...action, event: 'stale-discovery-entry' });
reject(game, f.fremen, action);
for (const source of ['homeworld:emperor', 'homeworld:emperor:salusa', `${f.face}:0`]) {
const groups = [{ source, normal: 1, elite: 0 }];
assert.equal(discoveryEntryMoveAction(view, groups), null); reject(game, f.owner, { ...action, groups });
}
const entered = applyAction(JSON.parse(JSON.stringify(game)), f.owner, action), after = player(entered, f.owner);
assert.equal(after.forces[`${f.face}:0`], f.amount); assert.equal(after.elites?.forces[`${f.face}:0`] ?? 0, f.elite);
assert.equal(after.forces[`${f.parent}:${f.parentSector}`] ?? 0, 0);
assert.deepEqual(entered.homeworlds!.custody, custody); assert.deepEqual(entered.leaderSkills!.assignments, training);
assert.equal(after.reserves, before.reserves); assert.equal(after.elites?.reserves, before.elites?.reserves);
assert.equal(after.spice, before.spice); assert.equal(after.moved, before.moved); assert.equal(after.shipped, before.shipped);
assert.ok(gameTerritories(entered).every(t => !t.id.startsWith('homeworld:'))); inventory(entered);
}
});
for (const advanced of [false, true]) {
void test(`${advanced ? 'Advanced' : 'Basic'} actual trained Suk nested win has no native bonus and returns only typed rescued pieces to real homes`, () => {
const f = battleFixture({ advanced });
const owner = f.owner, opponent = f.opponent!;
const reinforcedBefore = player(f.beforeReinforcement, owner), reinforcedAfter = player(f.afterReinforcement, owner), reinforcement = advanced ? 2 : 3;
assert.equal(reinforcedAfter.spice, reinforcedBefore.spice - reinforcement, 'One spice per real nested reinforcement, not a Homeworld invasion price.');
assert.equal(reinforcedAfter.reserves, reinforcedBefore.reserves - reinforcement);
assert.equal(reinforcedAfter.forces[`${f.face}:0`], 5);
assert.equal(player(f.game, opponent).forces[`${f.face}:0`], 2);
for (const actor of [owner, opponent]) legalPolicies(f.game, actor);
const revealed = revealBattle(f), quote = quoteClassicHomeworldSkillsBattle(revealed), own = revealed.battle!.attacker === owner ? 'attacker' : 'defender';
assert.equal(quote.winner, owner);
assert.equal(quote.scores![own], 4 + player(revealed, owner).leaders.find(l => l.id === f.trainer)!.strength);
assert.equal(quote.leaderSkillBonuses[own].bonus, 0, 'Suk rescue is not native Homeworld strength or a battle-strength skill.');
assert.deepEqual(viewGame(revealed, owner).battle!.traitorVoters.sort(), [owner, opponent].sort(), 'Nested Arrakis battle retains both ordinary traitor-call owners.');
const pending = settle(revealed, 'suk'); assert.ok(pending.decision?.kind === 'sukRescue');
const receipt = pending.pendingSukRescue!, choice = rescueChoice(pending), option = pending.decision.options[choice], destinations = splitReturn(pending, choice);
assert.equal(receipt.territory, f.face); assert.equal(receipt.skill.mode, 'skilled');
assert.deepEqual(receipt.pool, [{ key: `${f.face}:0`, normal: advanced ? 4 : 5, elite: advanced ? 1 : 0 }]);
assert.equal(receipt.losses!.normal, advanced ? 2 : 4); assert.equal(receipt.losses!.elite, advanced ? 1 : 0);
assert.equal(option.normal + option.elite, 3); assert.deepEqual(option.kept, { key: `${f.face}:0`, kind: 'normal' });
legalPolicies(pending, owner);
const action: Action = { type: 'decision', event: pending.decision.event, choice, ...(destinations ? { destinations } : {}) };
reject(pending, owner, { ...action, event: 'stale-suk-rescue' });
reject(pending, opponent, action);
reject(pending, owner, { ...action, destinations: { [`${f.face}:0`]: { normal: 2, elite: 0 } } });
reject(pending, owner, { ...action, destinations: { 'homeworld:guild': { normal: 2, elite: 0 } } });
if (advanced) {
assert.equal(pending.decision.reserveHomes?.length, 2);
assert.deepEqual(destinations, { 'homeworld:emperor': { normal: 0, elite: 1 }, 'homeworld:emperor:salusa': { normal: 1, elite: 0 } });
reject(pending, owner, { type: 'decision', event: pending.decision.event, choice });
reject(pending, owner, { ...action, destinations: { 'homeworld:emperor': { normal: 1, elite: 0 } } });
reject(pending, owner, { ...action, destinations: { 'homeworld:emperor': { normal: 0, elite: 2 }, 'homeworld:emperor:salusa': { normal: 0, elite: 0 } } });
}
const physical = quoteSukPhysicalRescue(physicalContext(pending), pending.homeworlds!.custody!, {
player: owner, territory: f.face, skill: receipt.skill, pool: receipt.pool, losses: receipt.losses!, option, destinations,
});
assert.equal(physical.receipt.source, 'arrakis');
const before = structuredClone(player(pending, owner)), homeBefore = homeworldForceGroups(homeworldContext(pending), pending.homeworlds!.custody!);
const done = settle(applyAction(JSON.parse(JSON.stringify(pending)), owner, action)), after = player(done, owner);
assert.equal(done.lastBattleContext!.winner, owner);
assert.equal(after.forces[`${f.face}:0`], advanced ? 3 : 2, 'Only undialed pieces plus the one physically kept ordinary counter remain.');
assert.equal(after.elites?.forces[`${f.face}:0`] ?? 0, 0);
assert.equal(after.reserves, before.reserves + 2); assert.equal(after.elites?.reserves ?? 0, (before.elites?.reserves ?? 0) + (advanced ? 1 : 0));
assert.equal(after.tanks, before.tanks + (advanced ? 0 : 1));
assert.equal(before.spice, reinforcedAfter.spice - (advanced ? 2 : 0), 'Actual support is paid before rescue and independent Collection.');
assert.equal(player(done, opponent).forces[`${f.face}:0`] ?? 0, 0);
assert.equal(player(done, opponent).tanks, player(f.game, opponent).tanks + 2);
assert.deepEqual(done.homeworlds!.custody, physical.custody);
const homeAfter = homeworldForceGroups(homeworldContext(done), done.homeworlds!.custody!);
if (advanced) for (const home of homeBefore.filter(h => h.native === owner)) {
const afterHome = homeAfter.find(h => h.id === home.id)!, returned = destinations![home.id];
assert.equal(afterHome.forces[owner].normal, home.forces[owner].normal + returned.normal);
assert.equal(afterHome.forces[owner].elite, home.forces[owner].elite + returned.elite);
}
assert.equal(player(done, owner).leaders.find(l => l.id === f.trainer)!.dead, false);
assert.ok(done.leaderSkills!.assignments.some(a => a.owner === owner && a.leader === f.trainer && a.skill === 'suk-graduate'));
assert.equal(done.pendingSukRescue ?? null, null); inventory(done);
const later = advance(done, g => g.phase === 7 && clean(g));
assert.equal(player(later, owner).spice, before.spice + 2, 'The surviving nested army receives printed Cistern income, not a support refund.');
assert.equal(player(later, owner).forces[`${f.face}:0`], after.forces[`${f.face}:0`]); inventory(later);
});
void test(`${advanced ? 'Advanced' : 'Basic'} original native and visitor Homeworld Suk rescues still use their proper physical pools with Discovery retained`, () => {
for (const kind of ['native-suk', 'visitor-suk'] as const) {
const initial = lobby({ advanced, roster: ['emperor', 'guild', 'atreides', 'harkonnen'] });
const f = createClassicHomeworldSkillsFixture({ initial, advanced, kind }), pending = settleClassicHomeworldSkillsBattle(revealClassicHomeworldSkillsBattle(f), 'suk');
assert.ok(pending.decision?.kind === 'sukRescue');
const choice = rescueChoice(pending, kind === 'native-suk' && advanced ? 'elite' : 'normal'), option = pending.decision.options[choice], receipt = pending.pendingSukRescue!;
const destinations = splitReturn(pending, choice);
if (kind === 'native-suk') {
assert.equal(pending.decision.reserveHomes, undefined);
reject(pending, f.owner, { type: 'decision', event: pending.decision.event, choice, destinations: { 'homeworld:emperor': { normal: 1, elite: 0 } } });
}
const physical = quoteSukPhysicalRescue(physicalContext(pending), pending.homeworlds!.custody!, {
player: f.owner, territory: f.territory, skill: receipt.skill, pool: receipt.pool, losses: receipt.losses!, option, destinations,
});
assert.equal(physical.receipt.source, kind === 'native-suk' ? 'native-homeworld' : 'visitor-homeworld');
legalPolicies(pending, f.owner);
const done = settleClassicHomeworldSkillsBattle(applyAction(pending, f.owner,
{ type: 'decision', event: pending.decision.event, choice, ...(destinations ? { destinations } : {}) }));
assert.deepEqual(done.homeworlds!.custody, physical.custody);
if (kind === 'native-suk' && advanced) assert.deepEqual(done.homeworlds!.custody!.salusa, pending.homeworlds!.custody!.salusa, 'Saved native Sardaukar never leave their original Salusa pool.');
assert.ok(done.leaderSkills!.assignments.some(a => a.leader === f.trainer && a.skill === 'suk-graduate'));
inventory(done);
}
});
}