Compose native Homeworld skill and Discovery modules

This commit is contained in:
Quintine committed 2026-10-07 05:16:26 +11:00
1 parent a8758bc6b5
commit 3fb047c93d
23 files changed
+1732 -107

No files matched your search

+3 -4
View File
@@ -45,10 +45,9 @@ void test('Discovery cannot redeal started components or silently remove another
const initial = lobby(['ixians', 'tleilaxu', 'guild'], ['ix']);
const started = initializeDiscoveryGameForAudit(initial);
assert.throws(() => initializeDiscoveryGameForAudit(started), RuleError);
const overlays: Game[] = [structuredClone(initial), structuredClone(initial), structuredClone(initial)];
overlays[0].homeworlds = { custody: null };
overlays[1].nexusCards = { cards: null, phase: null };
overlays[2].advanced = false; overlays[2].strongholdCards = createStrongholdCards();
const overlays: Game[] = [structuredClone(initial), structuredClone(initial)];
overlays[0].nexusCards = { cards: null, phase: null };
overlays[1].advanced = false; overlays[1].strongholdCards = createStrongholdCards();
for (const game of overlays) assert.throws(() => initializeDiscoveryGameForAudit(game), RuleError);
});
+4 -2
View File
@@ -3,7 +3,7 @@ import {
applyAction, createGame, initializeDiscoveryGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game,
} from '../game/engine';
import { classicHomeworldDiscoveryProfile } from '../game/discovery-module-profile';
import { homeworldDiscoveryProfile } from '../game/discovery-module-profile';
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';
@@ -42,6 +42,8 @@ export const homeworldClassicDiscoveryPlayer = (game: Game, actor: string) => {
export function nextHomeworldClassicDiscoveryStep(game: Game): HomeworldClassicDiscoveryStep {
if (!game.phaseOpening && !game.response) {
const decision = game.decision;
if (decision?.kind === 'homeworldShipmentGuild')
return { actor: decision.player, action: { type: 'decision', event: decision.event, allow: true } };
if (decision?.kind === 'discoveryEntry' || decision?.kind === 'greatMakerRide')
return { actor: decision.player, action: { type: 'decision', event: decision.event, accept: false } };
if (decision?.kind === 'greatMakerVote')
@@ -118,7 +120,7 @@ export function createHomeworldClassicDiscoveryFixture(options: HomeworldClassic
game.discoveryEnabled = true;
}
assert.ok(game.status === 'lobby' || (game.status === 'setup' && game.turn === 1 && game.phase === 0));
assert.ok(classicHomeworldDiscoveryProfile(game));
assert.ok(homeworldDiscoveryProfile(game));
const collector = game.players.find(player => player.faction === 'emperor')!.id;
const fremen = game.players.find(player => player.faction === 'fremen')!.id;
assert.ok(collector && fremen);
@@ -0,0 +1,294 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeDiscoveryGameForAudit, initializeLeaderSkillsGameForAudit,
joinGame, newPlayer, viewGame, type Action, type Game, type Player,
} from '../game/engine';
import type { FactionId } from '../game/catalog';
import { treacheryDeck, type Card, type SpiceCard } from '../game/cards';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
import { homeworldForceGroups } from '../game/homeworld-custody';
import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_TOKEN_BY_ID, validateDiscoveryState,
type DiscoveryLocationId, type DiscoveryOpaqueTokenId } from '../game/discoveries';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import { nextDiscoveryNativeE2SkillsStep } from './fixture-discovery-native-e2-skills';
import { nextHomeworldSkillsDiscoveryStep } from './fixture-homeworld-skills-discovery';
export type NativeHomeworldModuleStep = { actor: string; action: Action };
export type NativeHomeworldModuleOptions = {
advanced?: boolean;
expansions?: readonly string[];
roster?: readonly FactionId[];
skills?: boolean;
discovery?: boolean;
tech?: boolean;
strongholds?: boolean;
skill?: LeaderSkillId;
/** Select original undealt deck faces, never a held hand or physical owner. */
startingKinds?: readonly Card['kind'][];
};
export type NativeHomeworldModuleSetup = {
initial: Game;
offered: Game | null;
afterSetup: Game;
actions: NativeHomeworldModuleStep[];
};
export type NativeHomeworldSukProgramme = NativeHomeworldModuleSetup & {
game: Game;
beforeShipment: Game;
afterShipment: Game;
owner: string;
opponent: string;
trainer: string;
territory: string;
band: 'normal' | 'skilled';
plans: NativeHomeworldModuleStep[];
};
export type NativeHomeworldDiscoveryProgramme = NativeHomeworldModuleSetup & {
game: Game;
owner: string;
token: DiscoveryOpaqueTokenId;
face: DiscoveryLocationId;
parent: string;
parentSector: number;
beforeShipment: Game;
afterShipment: Game;
};
export const nativeHomeworldClean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
export function nativeHomeworldPlayer(game: Game, actor: string): Player {
const player = game.players.find(p => p.id === actor); assert.ok(player); return player;
}
export function nativeHomeworldArmy(game: Game, actor: string, location: string) {
const home = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!).find(h => h.id === location);
assert.ok(home); return home.forces[actor] ?? { normal: 0, elite: 0 };
}
export function nativeHomeworldInventory(game: Game): void {
homeworldGameIntegrity(game);
if (game.leaderSkills) validateLeaderSkills(game.leaderSkills, game.players);
if (game.discoveries) validateDiscoveryState(game.discoveries);
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
...(game.ixSetupCards ?? []), ...(game.auction?.cards.slice(game.auction.index) ?? [])];
const ordinary = cards.filter(c => !c.id.startsWith('richese-'));
assert.deepEqual(ordinary.map(c => c.id).sort(), treacheryDeck(game.expansions).map(c => c.id).sort());
const homes = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!);
for (const p of game.players) {
const visitors = homes.filter(h => h.native !== p.id).map(h => h.forces[p.id] ?? { normal: 0, elite: 0 });
const board = Object.values(p.forces).reduce((n, count) => n + count, 0);
const visitorCount = visitors.reduce((n, group) => n + group.normal + group.elite, 0);
assert.equal(p.reserves + p.tanks + board + visitorCount, 20, p.faction);
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks +
Object.values(p.elites.forces).reduce((n, count) => n + count, 0) + visitors.reduce((n, g) => n + g.elite, 0),
p.faction === 'ixians' ? 7 : p.faction === 'emperor' ? 5 : 3, p.faction);
}
}
function permutationDraws<T>(source: readonly T[], desired: readonly T[]): number[] {
const current = [...source], rolls: number[] = [];
for (let i = current.length - 1; i > 0; i--) {
const j = current.indexOf(desired[i]); assert.ok(j >= 0 && j <= i);
rolls.push(Math.floor((j + 0.5) / (i + 1) * 0x100000000));
[current[i], current[j]] = [current[j], current[i]];
}
assert.deepEqual(current, desired); return rolls;
}
/** Entropy selection is confined to original setup or an unused supply lottery.
* Every offer, hand, traitor, Face Dancer and native inventory is engine-created. */
function originalLottery<T>(rolls: readonly number[], operation: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = crypto.getRandomValues.bind(crypto); let draw = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[draw++] ?? 0xffffffff;
else Reflect.apply(native, crypto, [array]);
return array;
};
try { return operation(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
export function nextNativeHomeworldModuleStep(game: Game, hideOwner = 'ixians'): NativeHomeworldModuleStep {
if (!game.response && !game.phaseOpening && game.decision) {
const d = game.decision;
if (d.kind === 'discoveryEntry' || d.kind === 'greatMakerRide')
return { actor: d.player, action: { type: 'decision', event: d.event, accept: false } };
if (d.kind === 'greatMakerVote')
return { actor: d.player, action: { type: 'decision', event: d.event, yes: false } };
if (d.kind === 'leaderSkillVisibility') return { actor: d.player, action: {
type: 'leaderSkillVisibility', event: d.event, hide: d.player === hideOwner,
} };
if (d.kind === 'faceDance') return { actor: d.player, action: { type: 'decision', reveal: false } };
if (d.kind === 'ixSubstitution') return { actor: d.player, action: { type: 'decision', decline: true } };
if (d.kind === 'sukRescue') throw Error('Choose the original physical Suk rescue explicitly.');
if (d.kind === 'battleLosses' && game.pendingSukRescue) {
const maximum = Math.max(...d.options.map(o => o.elite));
return { actor: d.player, action: { type: 'decision', choice: d.options.findIndex(o => o.elite === maximum) } };
}
if (d.kind === 'techToken') return { actor: d.player, action: { type: 'decision', token: d.choices[0] } };
}
if (game.players.some(p => p.faction === 'richese')) return nextDiscoveryNativeE2SkillsStep(game, true);
const next = nextHomeworldSkillsDiscoveryStep(game); assert.ok(next, 'Original sealed plans are explicit.'); return next;
}
export function actNativeHomeworld(game: Game, actor: string, action: Action, actions?: NativeHomeworldModuleStep[]): Game {
actions?.push({ actor, action: structuredClone(action) }); return applyAction(game, actor, action);
}
export function advanceNativeHomeworld(state: Game, until: (game: Game) => boolean,
actions?: NativeHomeworldModuleStep[], hideOwner = 'ixians'): Game {
let game = state;
for (let count = 0; count < 2000; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextNativeHomeworldModuleStep(game, hideOwner); game = actNativeHomeworld(game, step.actor, step.action, actions);
}
throw Error('Original native Homeworld programme did not reach its requested window.');
}
export function createNativeHomeworldModuleSetup(options: NativeHomeworldModuleOptions = {}): NativeHomeworldModuleSetup {
const roster = options.roster ?? ['ixians', 'tleilaxu', 'guild', 'fremen'];
const skills = options.skills ?? true, discovery = options.discovery ?? true;
assert.ok(skills || discovery);
let game = createGame('NATIVEHWMODULES', newPlayer(roster[0], roster[0], roster[0]), options.advanced ?? false,
[...(options.expansions ?? ['ix'])]);
for (const faction of roster.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
game = applyAction(game, game.host, { type: 'homeworlds', enabled: true });
if (options.tech) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
if (options.strongholds) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true });
if (discovery) game.discoveryEnabled = true;
for (const p of game.players) game = applyAction(game, p.id, { type: 'ready' });
const initial = structuredClone(game), actions: NativeHomeworldModuleStep[] = [];
const selected: LeaderSkillId[] = [options.skill ?? 'suk-graduate'];
const passive: LeaderSkillId[] = ['prana-bindu-adept', 'swordmaster-of-ginaz', 'killer-medic', 'warmaster', 'sandmaster'];
for (let i = 1; i < roster.length; i++) selected.push(passive.find(id => !selected.includes(id))!);
const canonical = LEADER_SKILL_CARDS.map(c => c.id), rest = canonical.filter(id => !selected.includes(id));
const desired = selected.flatMap(id => [id, rest.shift()!]).concat(rest);
const cards = treacheryDeck(game.expansions), remaining = [...cards];
const prefix = (options.startingKinds ?? roster.map((_, i) => i < 5 ? 'worthless' as const : 'shield' as const)).map(kind => {
const index = remaining.findIndex(c => c.kind === kind); assert.ok(index >= 0); return remaining.splice(index, 1)[0];
});
const rolls = [...(skills ? permutationDraws(canonical, desired) : []),
...(discovery ? Array.from({ length: 7 }, () => 0xffffffff) : []),
...permutationDraws(cards.map(c => c.id), [...prefix, ...remaining].map(c => c.id))];
game = originalLottery(rolls, () => skills ? initializeLeaderSkillsGameForAudit(game) : initializeDiscoveryGameForAudit(game));
let offered: Game | null = null;
for (let count = 0; game.status === 'setup' && count < 250; count++) {
let step: NativeHomeworldModuleStep;
if (game.setupStage === 'leaderSkills' && !game.decision) {
offered ??= structuredClone(game);
const actor = Object.keys(game.leaderSkills!.offers)[0], view = viewGame(game, actor).leaderSkills!;
const skill = selected[game.players.findIndex(p => p.id === actor)], offer = view.offer!;
assert.ok(offer.cards.includes(skill) && !view.unavailableSkills?.[skill]);
const eligible = new Set(view.eligibleLeaders.map(l => l.id));
const leader = nativeHomeworldPlayer(game, actor).leaders.filter(l => eligible.has(l.id)).sort((a, b) => b.strength - a.strength)[0];
assert.ok(leader); step = { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } };
} else if (game.decision?.kind === 'ixSetup') {
const actor = game.decision.player, card = viewGame(game, actor).ixTechnology!.setup!.find(c => c.kind === 'worthless') ?? viewGame(game, actor).ixTechnology!.setup![0];
step = { actor, action: { type: 'decision', card: card.id } };
} else if (game.setupStage === 'traitors' && game.players.some(p => p.traitorChoices.length)) {
const p = game.players.find(p => p.traitorChoices.length)!;
const leader = p.traitorChoices.find(id => !['ixians-0', 'ixians-1', 'ixians-2'].includes(id)) ?? p.traitorChoices[0];
step = { actor: p.id, action: { type: 'traitor', leader } };
} else step = nextNativeHomeworldModuleStep(game);
game = originalLottery([], () => actNativeHomeworld(game, step.actor, step.action, actions));
}
assert.equal(game.status, 'playing'); nativeHomeworldInventory(game);
return { initial, offered, afterSetup: structuredClone(game), actions };
}
export function nativeHomeworldMovement(state: Game, actor: string, turn = 1): Game {
return advanceNativeHomeworld(state, game => game.turn === turn && game.phase === 5 && game.active === actor && nativeHomeworldClean(game));
}
export function settleNativeHomeworldArrival(state: Game): Game {
return advanceNativeHomeworld(state, game => nativeHomeworldClean(game) && !game.pendingShipment && !game.pendingHomeworldShipment);
}
export function invadeNativeHomeworld(game: Game, actor: string, destination: string, normal: number, elite = 0): Game {
const sources = nativeShipmentSources(viewGame(game, actor), normal + elite, elite); assert.ok(sources);
const choice = homeworldShipmentChoice(viewGame(game, actor), destination,
Object.fromEntries(Object.entries(sources).filter(([, g]) => g.normal + g.elite > 0)));
assert.ok(choice.action, choice.blocked ?? undefined);
return settleNativeHomeworldArrival(applyAction(game, actor, choice.action));
}
export function openNativeHomeworldBattle(state: Game, first: string, second: string, territory: string, hideOwner = 'ixians'): Game {
let game = advanceNativeHomeworld(state, g => g.phase === 6 && nativeHomeworldClean(g) && !!g.active, undefined, hideOwner);
const actor = game.active!; assert.ok(actor === first || actor === second);
game = applyAction(game, actor, { type: 'chooseBattle', territory, target: actor === first ? second : first });
return advanceNativeHomeworld(game, g => !!g.battle && nativeHomeworldClean(g) && !g.battle.preparation && g.battle.preLeader?.closed !== false, undefined, hideOwner);
}
export function nativeHomeworldLeader(game: Game, actor: string, strongest = false): string {
const trained = new Set(game.leaderSkills?.assignments.map(a => a.leader));
const leader = nativeHomeworldPlayer(game, actor).leaders.filter(l => !l.dead && !trained.has(l.id) && !l.usedAt)
.sort((a, b) => strongest ? b.strength - a.strength : a.strength - b.strength)[0]; assert.ok(leader); return leader.id;
}
export function createNativeHomeworldSukProgramme(options: NativeHomeworldModuleOptions & {
visitor?: boolean; band?: 'normal' | 'skilled';
} = {}): NativeHomeworldSukProgramme {
const setup = createNativeHomeworldModuleSetup({ ...options, skill: 'suk-graduate' });
const owner = 'ixians', opponent = 'guild', band = options.band ?? 'skilled';
const territory = options.visitor ? 'homeworld:guild' : 'homeworld:ixians';
const actor = options.visitor ? owner : opponent;
let game = nativeHomeworldMovement(setup.afterSetup, actor);
const beforeShipment = structuredClone(game);
game = invadeNativeHomeworld(game, actor, territory, options.visitor ? 4 : 3, options.visitor ? 3 : 0);
const afterShipment = structuredClone(game);
game = openNativeHomeworldBattle(game, owner, opponent, territory, band === 'skilled' ? owner : '');
const trainer = game.leaderSkills!.assignments.find(a => a.owner === owner)!.leader;
// The real visibility choice must be completed before either sealed plan.
if (game.battle!.leaderSkillHidden?.[owner] !== (band === 'skilled')) {
throw Error('Native continuation must select the requested Suk visibility before plans.');
}
return { ...setup, game, beforeShipment, afterShipment, owner, opponent, trainer, territory, band,
plans: [{ actor: owner, action: { type: 'battlePlan', leader: band === 'skilled' ? trainer : nativeHomeworldLeader(game, owner, true),
dial: game.advanced ? 3 : 6, support: 0, weapon: nativeHomeworldPlayer(game, owner).hand.find(c => c.kind === 'worthless')!.id } },
{ actor: opponent, action: { type: 'battlePlan', leader: nativeHomeworldLeader(game, opponent), dial: 0, support: 0 } }] };
}
export function revealNativeHomeworldPlans(state: Game, plans: NativeHomeworldModuleStep[]): Game {
let game = state;
for (const plan of plans) {
game = applyAction(game, plan.actor, plan.action);
if (!game.battle?.revealed) game = advanceNativeHomeworld(game, g => !!g.battle && nativeHomeworldClean(g) && !g.battle.preparation);
}
assert.ok(game.battle?.revealed); return game;
}
export function frontNativeHomeworldSpice(game: Game, choose: (card: SpiceCard) => boolean, position: number): void {
const index = game.spiceDeck.findIndex((c, i) => i >= position && choose(c)); assert.ok(index >= position);
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
export function createNativeHomeworldDiscoveryProgramme(options: NativeHomeworldModuleOptions & {
face?: DiscoveryLocationId; collector?: string; maker?: boolean;
} = {}): NativeHomeworldDiscoveryProgramme {
const setup = createNativeHomeworldModuleSetup({ ...options, discovery: true });
let game = structuredClone(setup.afterSetup);
const owner = options.collector ?? game.players[0].id, face = options.face ?? 'cistern';
const card = DISCOVERY_TOKEN_BY_ID[face].type === 'hiereg' ? 'discovery-hagga-basin' : 'discovery-wind-pass-north';
const placement = DISCOVERY_CARD_PLACEMENTS[card];
frontNativeHomeworldSpice(game, c => 'territory' in c && c.discovery === card, 0);
const index = game.advanced ? 2 : 1;
if (game.advanced) frontNativeHomeworldSpice(game, c => 'territory' in c && !c.discovery, 1);
if (options.maker) frontNativeHomeworldSpice(game, c => 'worm' in c && !!c.greatMaker, index);
for (let i = index + (options.maker ? 1 : 0); i < index + 3; i++)
frontNativeHomeworldSpice(game, c => 'territory' in c && !c.discovery, i);
game = advanceNativeHomeworld(game, g => g.phase === 1 && nativeHomeworldClean(g), setup.actions);
const pool = game.discoveries!.tokens.filter(t => t.status === 'supply' && t.type === DISCOVERY_TOKEN_BY_ID[face].type);
const selected = pool.findIndex(t => t.face === face); assert.ok(selected >= 0);
game = originalLottery([Math.floor((selected + 0.5) / pool.length * 0x100000000)], () =>
advanceNativeHomeworld(game, g => g.discoveries!.tokens.some(t => t.face === face && t.status === 'placed'), setup.actions));
game = nativeHomeworldMovement(game, owner);
const beforeShipment = structuredClone(game), sources = nativeShipmentSources(viewGame(game, owner), 2, owner === 'ixians' ? 1 : 0); assert.ok(sources);
game = settleNativeHomeworldArrival(applyAction(game, owner, { type: 'ship', territory: placement.territory,
sector: placement.sector, amount: 2, elite: owner === 'ixians' ? 1 : 0, homeworldSources: sources, allyPayment: 0 }));
const afterShipment = structuredClone(game);
game = advanceNativeHomeworld(game, g => g.phase === 7 && nativeHomeworldClean(g));
const token = game.discoveries!.tokens.find(t => t.face === face && t.status === 'placed')!; assert.ok(token);
return { ...setup, game, owner, token: token.id, face, parent: placement.territory, parentSector: placement.sector, beforeShipment, afterShipment };
}
export function revealNativeHomeworldDiscovery(f: NativeHomeworldDiscoveryProgramme): Game {
let game = structuredClone(f.game);
if (viewGame(game, f.owner).discoveries!.canInspect.includes(f.token))
game = applyAction(game, f.owner, { type: 'discovery', token: f.token, reveal: false });
return applyAction(game, f.owner, { type: 'discovery', token: f.token, reveal: true });
}
export function enterNativeHomeworldDiscovery(f: NativeHomeworldDiscoveryProgramme): Game {
const game = advanceNativeHomeworld(revealNativeHomeworldDiscovery(f), g =>
g.turn === 2 && g.decision?.kind === 'discoveryEntry' && g.decision.player === f.owner);
const view = viewGame(game, f.owner), action = discoveryEntryMoveAction(view, view.discoveryEntry!.sources); assert.ok(action);
return applyAction(game, f.owner, action);
}
@@ -0,0 +1,397 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeDiscoveryGameForAudit, initializeLeaderSkillsGameForAudit,
joinGame, newPlayer, viewGame, type Action, type Game, type Player,
} from '../game/engine';
import type { FactionId } from '../game/catalog';
import { leaders, treacheryDeck } from '../game/cards';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_TOKEN_BY_ID, validateDiscoveryState } from '../game/discoveries';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
import { homeworldForceGroups, type HomeworldForces } from '../game/homeworld-custody';
import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { validateAmbassadors } from '../game/ecaz-ambassadors';
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
import { traitorDeck } from '../game/traitors';
import { nextEcazSkillsModulesStep } from './fixture-ecaz-skills-modules';
import { nextMoritaniSkillsModulesStep, moritaniSkillsModulesPolicy } from './fixture-moritani-skills-modules';
export type NativeE3HomeworldStep = { actor: string; action: Action };
export type NativeE3HomeworldOptions = {
/** Original undealt lobby: preserve seat IDs and order, without implying HTTP authentication. */
initial?: Game;
faction?: 'ecaz' | 'moritani';
advanced?: boolean;
roster?: readonly FactionId[];
skills?: boolean;
discovery?: boolean;
tech?: boolean;
strongholds?: boolean;
skill?: LeaderSkillId;
/** Choose the original Guild offer/Traitor lottery for the after-loss proof. */
assassination?: boolean;
lethal?: boolean;
};
export type NativeE3HomeworldSetup = {
initial: Game; offered: Game; afterSetup: Game;
owner: string; opponent: string; trainer: string | null;
actions: NativeE3HomeworldStep[];
};
export type NativeE3HomeworldBattle = NativeE3HomeworldSetup & {
visitor: boolean; location: string;
beforeShipment: Game; shipment: NativeE3HomeworldStep; afterShipment: Game;
beforeBattle: Game; game: Game; plans: NativeE3HomeworldStep[];
};
export type NativeE3HomeworldDeath = {
before: Game; after: Game; leader: string; weapon: string;
};
export type NativeE3HomeworldRevival = NativeE3HomeworldSetup & {
deaths: NativeE3HomeworldDeath[]; revivalWindow: Game; revival: NativeE3HomeworldStep; offeredRevival: Game;
};
export type NativeE3HomeworldDiscovery = NativeE3HomeworldSetup & {
beforeShipment: Game; shipment: NativeE3HomeworldStep; afterShipment: Game;
beforeReveal: Game; afterReveal: Game; token: string;
entryWindow: Game; entry: NativeE3HomeworldStep; entered: Game;
};
export const nativeE3HomeworldClean = (game: Game) =>
!game.response && !game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard;
export function nativeE3HomeworldPlayer(game: Game, actor: string): Player {
const player = game.players.find(p => p.id === actor); assert.ok(player); return player;
}
export const nativeE3HomeworldPolicy = moritaniSkillsModulesPolicy;
export function nativeE3HomeworldPool(game: Game, actor: string, location: string): HomeworldForces {
const home = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!).find(h => h.id === location);
assert.ok(home); return home.forces[actor] ?? { normal: 0, elite: 0 };
}
export function assertNativeE3HomeworldCustody(game: Game): void {
homeworldGameIntegrity(game);
if (game.leaderSkills) validateLeaderSkills(game.leaderSkills, game.players);
if (game.discoveries) validateDiscoveryState(game.discoveries);
const homes = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!);
for (const p of game.players) {
const visitors = homes.filter(h => h.native !== p.id).map(h => h.forces[p.id] ?? { normal: 0, elite: 0 });
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0) +
visitors.reduce((a, b) => a + b.normal + b.elite, 0), 20, p.faction);
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks +
Object.values(p.elites.forces).reduce((a, b) => a + b, 0) + visitors.reduce((a, b) => a + b.elite, 0),
p.faction === 'fremen' ? 3 : 5, p.faction);
assert.deepEqual(p.leaders.map(l => l.id).sort(), leaders(p.faction).map(l => l.id).sort());
}
assert.equal(game.players.find(p => ['ecaz', 'moritani'].includes(p.faction))!.leaders.length, 5);
assert.equal(game.dukeVidal!.leader.id, DUKE_VIDAL_ID);
assert.ok(!game.leaderSkills?.assignments.some(a => a.leader === DUKE_VIDAL_ID));
if (game.ecazAmbassadors) validateAmbassadors(game.ecazAmbassadors);
const retired = game.moritaniAssassinate?.opportunities.filter(r => r.stage === 'replaced').map(r => r.card!) ?? [];
const traitors = [...game.traitorReserve!, ...game.players.flatMap(p => [...p.traitors, ...p.traitorChoices]),
...retired, ...(game.ecazLoyalty?.card ? [game.ecazLoyalty.card] : [])];
assert.deepEqual(traitors.sort(), traitorDeck(game.players).sort());
const physical = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
...(game.auction?.cards.slice(game.auction.index) ?? [])];
assert.deepEqual(physical.map(c => c.id).sort(), treacheryDeck(['ecaz']).map(c => c.id).sort());
}
/** Fisher-Yates draw selection affects only components that have not been dealt. */
function permutation<T>(original: readonly T[], desired: readonly T[]): number[] {
const current = [...original], draws: number[] = [];
for (let i = current.length - 1; i > 0; i--) {
const j = current.indexOf(desired[i]); assert.ok(j >= 0 && j <= i);
draws.push(Math.floor((j + 0.5) / (i + 1) * 0x100000000));
[current[i], current[j]] = [current[j], current[i]];
}
assert.deepEqual(current, desired); return draws;
}
function lottery<T>(draws: readonly number[], operation: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto); let index = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1 && index < draws.length) array[0] = draws[index++];
else Reflect.apply(original, crypto, [array]);
return array;
};
try { return operation(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
export function actNativeE3Homeworld(game: Game, step: NativeE3HomeworldStep,
actions?: NativeE3HomeworldStep[]): Game {
actions?.push(structuredClone(step)); return applyAction(game, step.actor, step.action);
}
/** Explicit rescue and sealed plans remain boundaries; optional assassination defaults to decline. */
export function nextNativeE3HomeworldStep(game: Game): NativeE3HomeworldStep | null {
if (!game.response && !game.phaseOpening) {
const d = game.decision;
if (d?.kind === 'sukRescue') return null;
if (d?.kind === 'moritaniAssassinate')
return { actor: d.player, action: { type: 'decision', event: d.event, decline: true } };
if (d?.kind === 'discoveryEntry' || d?.kind === 'greatMakerRide')
return { actor: d.player, action: { type: 'decision', event: d.event, accept: false } };
if (d?.kind === 'greatMakerVote') return { actor: d.player, action: { type: 'decision', event: d.event, yes: false } };
if (d?.kind === 'wormProtection') return { actor: d.player, action: { type: 'decision', accept: true } };
if (d?.kind === 'wormRide') return { actor: d.player, action: { type: 'decision', accept: false } };
if (d?.kind === 'homeworldDefense') return { actor: d.player, action: { type: 'decision', event: d.event, use: false } };
if (d?.kind === 'homeworldRevivalDeployment') return { actor: d.player, action: { type: 'decision', event: d.event, decline: true } };
}
const next = game.players.some(p => p.faction === 'ecaz')
? nextEcazSkillsModulesStep(game) : nextMoritaniSkillsModulesStep(game);
if (next?.action.type === 'stormDial') next.action.amount = game.turn === 1 ? 0 : 1;
return next;
}
export function advanceNativeE3Homeworld(state: Game, until: (game: Game) => boolean,
actions?: NativeE3HomeworldStep[]): Game {
let game = state;
for (let n = 0; n < 2600; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const next = nextNativeE3HomeworldStep(game); assert.ok(next, 'An actual human-owned native window needs its owner.');
game = actNativeE3Homeworld(game, next, actions);
}
throw Error('Original native Homeworld programme did not reach its requested window.');
}
/** Reuse the original native initializer; no parallel game configuration or force staging. */
export function createNativeE3HomeworldSetup(options: NativeE3HomeworldOptions = {}): NativeE3HomeworldSetup {
const faction = options.faction ?? options.initial?.players.find(p => ['ecaz', 'moritani'].includes(p.faction))?.faction ?? 'ecaz';
const advanced = options.advanced ?? options.initial?.advanced ?? true;
const skills = options.skills ?? true, discovery = options.discovery ?? false;
const roster = options.roster ?? [faction, 'guild', 'emperor'] as FactionId[];
let game = options.initial ? structuredClone(options.initial)
: createGame('NATIVEHWE3MODULES', newPlayer(roster[0], roster[0], roster[0]), advanced, ['ecaz']);
if (!options.initial) for (const f of roster.slice(1)) joinGame(game, newPlayer(f, f, f));
assert.equal(game.status, 'lobby'); assert.equal(game.advanced, advanced); assert.deepEqual(game.expansions, ['ecaz']);
assert.equal(game.players.filter(p => ['ecaz', 'moritani'].includes(p.faction)).length, 1);
assert.ok(game.players.some(p => p.faction === faction));
assert.ok(game.players.every(p => p.faction === faction || ['guild', 'emperor', 'atreides', 'fremen', 'beneGesserit',
...(advanced ? [] : ['harkonnen'])].includes(p.faction)));
assert.ok(skills || discovery); assert.ok(!game.nexusCards && !game.ecazTreachery);
if (!game.homeworlds) game = applyAction(game, game.host, { type: 'homeworlds', enabled: true });
if (!!game.techTokens !== !!options.tech) game = applyAction(game, game.host, { type: 'techTokens', enabled: !!options.tech });
if (!!game.strongholdCards !== !!options.strongholds) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: !!options.strongholds });
game.discoveryEnabled = discovery;
const initial = structuredClone(game), actions: NativeE3HomeworldStep[] = [];
const owner = game.players.find(p => p.faction === faction)!.id;
const opponent = game.players.find(p => p.faction === 'guild')?.id ?? game.players.find(p => p.id !== owner)!.id;
const requested = options.skill ?? 'suk-graduate';
const preferences: LeaderSkillId[] = game.players.map(p => p.id === owner ? options.assassination ? 'warmaster' : requested
: p.id === opponent && options.assassination ? 'suk-graduate'
: p.faction === 'atreides' ? 'bureaucrat' : p.faction === 'fremen' ? 'killer-medic'
: p.faction === 'beneGesserit' ? 'prana-bindu-adept' : p.faction === 'harkonnen' ? 'smuggler'
: p.faction === 'guild' ? 'swordmaster-of-ginaz' : 'master-of-assassins');
const canonical = LEADER_SKILL_CARDS.map(c => c.id), remainder = canonical.filter(s => !preferences.includes(s));
const desiredSkills = preferences.flatMap(s => [s, remainder.shift()!]).concat(remainder);
const cards = treacheryDeck(['ecaz']), remaining = [...cards], prefix = game.players.flatMap(p => {
const kinds = [p.id === opponent && options.lethal ? 'projectile' : 'worthless', ...(p.faction === 'harkonnen' ? ['shield'] : [])];
return kinds.map(kind => {
const match = remaining.findIndex(c => c.kind === kind);
const i = match >= 0 ? match : remaining.findIndex(c => c.kind === 'shield' || c.kind === 'snooper');
assert.ok(i >= 0); return remaining.splice(i, 1)[0].id;
});
});
for (const p of game.players) if (!p.ready) game = actNativeE3Homeworld(game, { actor: p.id, action: { type: 'ready' } }, actions);
const draws = [...(skills ? permutation(canonical, desiredSkills) : []), ...(discovery ? Array<number>(7).fill(0xffffffff) : []),
...permutation(cards.map(c => c.id), [...prefix, ...remaining.map(c => c.id)])];
game = lottery(draws, () => skills ? initializeLeaderSkillsGameForAudit(game) : initializeDiscoveryGameForAudit(game));
const offered = structuredClone(game);
for (let n = 0; game.status === 'setup' && n < 240; n++) {
let step: NativeE3HomeworldStep;
if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0], own = viewGame(game, actor).leaderSkills!;
const skill = preferences[game.players.findIndex(p => p.id === actor)];
assert.ok(own.offer!.cards.includes(skill) && !own.unavailableSkills?.[skill]);
const eligible = own.eligibleLeaders.map(l => nativeE3HomeworldPlayer(game, actor).leaders.find(d => d.id === l.id)!)
.sort((a, b) => b.strength - a.strength);
const leader = options.assassination && actor === opponent ? eligible.find(l => l.name === 'Master Bewt') : eligible[0];
assert.ok(leader);
step = { actor, action: { type: 'leaderSkill', event: own.offer!.event, skill, leader: leader.id } };
if (options.assassination && Object.keys(game.leaderSkills!.offers).length === 1) {
const target = leaders('guild').find(l => l.name === 'Master Bewt')!.id;
const traitors = traitorDeck(game.players), desired = [target, ...traitors.filter(t => t !== target)];
const ownerIndex = game.players.findIndex(p => p.id === owner);
[desired[0], desired[ownerIndex * 4]] = [desired[ownerIndex * 4], desired[0]];
game = lottery([...Array<number>(game.leaderSkills!.deck.length).fill(0xffffffff),
...permutation(traitors, desired)], () => actNativeE3Homeworld(game, step, actions));
continue;
}
} else if (game.setupStage === 'traitors' && options.assassination && nativeE3HomeworldPlayer(game, owner).traitorChoices.length) {
const target = leaders('guild').find(l => l.name === 'Master Bewt')!.id;
assert.ok(nativeE3HomeworldPlayer(game, owner).traitorChoices.includes(target));
step = { actor: owner, action: { type: 'traitor', leader: target } };
} else { const next = nextNativeE3HomeworldStep(game); assert.ok(next); step = next; }
game = actNativeE3Homeworld(game, step, actions);
}
assert.equal(game.status, 'playing'); assertNativeE3HomeworldCustody(game);
return { initial, offered, afterSetup: structuredClone(game), owner, opponent,
trainer: game.leaderSkills?.assignments.find(a => a.owner === owner)?.leader ?? null, actions };
}
function movement(game: Game, actor: string, actions: NativeE3HomeworldStep[]): Game {
return advanceNativeE3Homeworld(game, g => g.phase === 5 && g.active === actor && nativeE3HomeworldClean(g), actions);
}
function invasion(game: Game, actor: string, destination: string, amount: number): NativeE3HomeworldStep {
const sources = nativeShipmentSources(viewGame(game, actor), amount, 0); assert.ok(sources);
const selected = Object.fromEntries(Object.entries(sources).filter(([, group]) => group.normal + group.elite > 0));
const choice = homeworldShipmentChoice(viewGame(game, actor), destination, selected);
assert.ok(choice.action, choice.blocked ?? 'Original invasion is unavailable.'); return { actor, action: choice.action };
}
function arrive(game: Game, actions: NativeE3HomeworldStep[]): Game {
return advanceNativeE3Homeworld(game, g => !g.pendingShipment && !g.pendingHomeworldShipment && nativeE3HomeworldClean(g), actions);
}
export function openNativeE3HomeworldBattle(state: Game, location: string, first: string, second: string,
actions: NativeE3HomeworldStep[] = [], hide = true): Game {
let game = advanceNativeE3Homeworld(state, g => g.phase === 6 && nativeE3HomeworldClean(g) && !!g.active, actions);
assert.ok(game.active === first || game.active === second);
game = actNativeE3Homeworld(game, { actor: game.active!, action: { type: 'chooseBattle', territory: location,
target: game.active === first ? second : first } }, actions);
for (let n = 0; n < 180; n++) {
if (game.battle && nativeE3HomeworldClean(game) && !game.battle.preparation && game.battle.preLeader?.closed !== false) return game;
const next = nextNativeE3HomeworldStep(game); assert.ok(next);
if (next.action.type === 'leaderSkillVisibility') next.action.hide = hide;
game = actNativeE3Homeworld(game, next, actions);
}
throw Error('Original Homeworld Battle Plan window did not open.');
}
export function createNativeE3HomeworldBattle(options: NativeE3HomeworldOptions & { visitor?: boolean; battleTurn?: 1 | 2 } = {}): NativeE3HomeworldBattle {
const setup = createNativeE3HomeworldSetup(options), visitor = options.visitor ?? false;
const location = `homeworld:${nativeE3HomeworldPlayer(setup.afterSetup, visitor ? setup.opponent : setup.owner).faction}`;
const actor = visitor ? setup.owner : setup.opponent;
let game = structuredClone(setup.afterSetup);
for (let position = 0; position < (game.advanced ? 4 : 2); position++)
frontSpice(game, c => 'territory' in c && !c.discovery, position);
if (options.battleTurn === 2) game = advanceNativeE3Homeworld(game,
g => g.turn === 2 && g.phase === 0 && nativeE3HomeworldClean(g), setup.actions);
game = movement(game, actor, setup.actions);
const beforeShipment = structuredClone(game), shipment = invasion(game, actor, location, 5);
game = arrive(actNativeE3Homeworld(game, shipment, setup.actions), setup.actions);
const afterShipment = structuredClone(game);
game = advanceNativeE3Homeworld(game, g => g.phase === 6 && nativeE3HomeworldClean(g), setup.actions);
const beforeBattle = structuredClone(game);
game = openNativeE3HomeworldBattle(game, location, setup.owner, setup.opponent, setup.actions, !options.assassination);
const trained = new Set(game.leaderSkills?.assignments.map(a => a.leader) ?? []);
const enemy = nativeE3HomeworldPlayer(game, setup.opponent).leaders.filter(l => !l.dead && !trained.has(l.id))
.sort((a, b) => options.lethal || options.assassination ? b.strength - a.strength : a.strength - b.strength)[0];
const ownerLeader = options.assassination ? nativeE3HomeworldPlayer(game, setup.owner).leaders.filter(l => !trained.has(l.id))
.sort((a, b) => a.strength - b.strength)[0].id : setup.trainer ?? nativeE3HomeworldPlayer(game, setup.owner).leaders[0].id;
const weapon = options.lethal ? nativeE3HomeworldPlayer(game, setup.opponent).hand.find(c => c.kind === 'projectile')?.id : undefined;
if (options.lethal) assert.ok(weapon);
const worthless = nativeE3HomeworldPlayer(game, setup.opponent).hand.find(c => c.kind === 'worthless')?.id;
const dial = options.lethal || options.assassination ? 0 : 4;
const plans: NativeE3HomeworldStep[] = [
{ actor: setup.owner, action: { type: 'battlePlan', leader: ownerLeader, dial, support: game.advanced ? dial : 0 } },
{ actor: setup.opponent, action: { type: 'battlePlan', leader: enemy.id, dial: options.assassination ? 3 : 0,
support: game.advanced && options.assassination ? 3 : 0, weapon: weapon ?? worthless } },
];
return { ...setup, visitor, location, beforeShipment, shipment, afterShipment, beforeBattle, game, plans };
}
export function revealNativeE3HomeworldPlans(state: Game, plans: NativeE3HomeworldStep[],
actions?: NativeE3HomeworldStep[]): Game {
let game = structuredClone(state);
for (const step of plans) {
game = actNativeE3Homeworld(game, step, actions);
if (!game.battle?.revealed) game = advanceNativeE3Homeworld(game,
g => nativeE3HomeworldClean(g) && !!g.battle && !g.battle.preparation && g.battle.preLeader?.closed !== false, actions);
}
assert.ok(game.battle?.revealed); return game;
}
export function settleNativeE3HomeworldBattle(state: Game, stop: 'suk' | 'assassination' | 'tech' | 'complete' = 'complete',
actions?: NativeE3HomeworldStep[]): Game {
return advanceNativeE3Homeworld(state, g => !g.response && !g.phaseOpening && (
stop === 'suk' && g.decision?.kind === 'sukRescue' ||
stop === 'assassination' && g.decision?.kind === 'moritaniAssassinate' ||
stop === 'tech' && g.decision?.kind === 'techToken' || !g.battle && nativeE3HomeworldClean(g)), actions);
}
export function rescueNativeE3Homeworld(state: Game): Game {
const d = state.decision; assert.ok(d?.kind === 'sukRescue');
const maximum = Math.max(...d.options.map(o => o.normal + o.elite));
const choice = d.options.findIndex(o => o.normal + o.elite === maximum && o.kept?.kind === 'normal');
assert.ok(choice >= 0); return applyAction(state, d.player, { type: 'decision', event: d.event, choice });
}
/** All five deaths and revival are played. Each later visitor arrives by a new
* paid invasion; the original native Guild retains its real winning projectile. */
export function createNativeE3HomeworldRevival(options: NativeE3HomeworldOptions = {}): NativeE3HomeworldRevival {
const setup = createNativeE3HomeworldSetup({ ...options, skills: true, lethal: true, skill: 'mentat' });
let game = structuredClone(setup.afterSetup);
for (let position = 0; position < 12; position++)
frontSpice(game, c => 'territory' in c && !c.discovery, position);
const location = 'homeworld:guild', deaths: NativeE3HomeworldDeath[] = [];
const discs = nativeE3HomeworldPlayer(game, setup.owner).leaders.map(l => l.id);
const weapon = nativeE3HomeworldPlayer(game, setup.opponent).hand.find(c => c.kind === 'projectile')!.id;
const winner = nativeE3HomeworldPlayer(game, setup.opponent).leaders.filter(l =>
!game.leaderSkills!.assignments.some(a => a.leader === l.id)).sort((a, b) => b.strength - a.strength)[0].id;
for (const leader of discs) {
game = movement(game, setup.owner, setup.actions);
game = arrive(actNativeE3Homeworld(game, invasion(game, setup.owner, location, 1), setup.actions), setup.actions);
game = openNativeE3HomeworldBattle(game, location, setup.owner, setup.opponent, setup.actions);
const before = structuredClone(game);
game = revealNativeE3HomeworldPlans(game, [
{ actor: setup.owner, action: { type: 'battlePlan', leader, dial: 0, support: 0 } },
{ actor: setup.opponent, action: { type: 'battlePlan', leader: winner, dial: 0, support: 0, weapon } },
], setup.actions);
game = advanceNativeE3Homeworld(game, g => !g.battle && nativeE3HomeworldClean(g) || g.decision?.kind === 'moritaniAssassinate', setup.actions);
if (game.decision?.kind === 'moritaniAssassinate') {
game = actNativeE3Homeworld(game, { actor: game.decision.player,
action: { type: 'decision', event: game.decision.event, decline: true } }, setup.actions);
game = settleNativeE3HomeworldBattle(game, 'complete', setup.actions);
}
deaths.push({ before, after: structuredClone(game), leader, weapon });
const turn = game.turn;
game = advanceNativeE3Homeworld(game, g => g.turn === turn + 1 && g.phase === 4 && nativeE3HomeworldClean(g), setup.actions);
}
const revivalWindow = structuredClone(game), leader = setup.trainer!;
const revival: NativeE3HomeworldStep = { actor: setup.owner, action: { type: 'reviveLeader', leader } };
game = actNativeE3Homeworld(game, revival, setup.actions);
game = advanceNativeE3Homeworld(game, g => !!g.leaderSkills!.offers[setup.owner], setup.actions);
return { ...setup, deaths, revivalWindow, revival, offeredRevival: game };
}
function frontSpice(game: Game, predicate: (card: Game['spiceDeck'][number]) => boolean, position: number): void {
const i = game.spiceDeck.findIndex((c, n) => n >= position && predicate(c)); assert.ok(i >= position);
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(i, 1)[0]);
}
/** Only unplayed Spice order and the original supply lottery are selected.
* Reveal is followed by next-turn parent entry, not a direct nested placement. */
export function createNativeE3HomeworldDiscovery(options: NativeE3HomeworldOptions = {}): NativeE3HomeworldDiscovery {
const setup = createNativeE3HomeworldSetup({ ...options, discovery: true });
let game = structuredClone(setup.afterSetup);
frontSpice(game, c => 'territory' in c && c.discovery === 'discovery-hagga-basin', 0);
for (let position = 1; position < (game.advanced ? 4 : 2); position++)
frontSpice(game, c => 'territory' in c && !c.discovery && c.territory !== 'hagga_basin', position);
const face = 'cistern', type = DISCOVERY_TOKEN_BY_ID[face].type;
const pool = game.discoveries!.tokens.filter(t => t.status === 'supply' && t.type === type);
const index = pool.findIndex(t => t.face === face); assert.ok(index >= 0);
game = advanceNativeE3Homeworld(game, g => g.phase === 1 && nativeE3HomeworldClean(g), setup.actions);
game = lottery([Math.floor((index + 0.5) / pool.length * 0x100000000)], () =>
advanceNativeE3Homeworld(game, g => g.discoveries!.tokens.some(t => t.face === face && t.status === 'placed'), setup.actions));
game = movement(game, setup.owner, setup.actions);
const beforeShipment = structuredClone(game), destination = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
const sources = nativeShipmentSources(viewGame(game, setup.owner), 2, 0); assert.ok(sources);
const shipment: NativeE3HomeworldStep = { actor: setup.owner, action: { type: 'ship', territory: destination.territory,
sector: destination.sector, amount: 2, elite: 0, allyPayment: 0, homeworldSources: sources } };
game = arrive(actNativeE3Homeworld(game, shipment, setup.actions), setup.actions);
const afterShipment = structuredClone(game);
game = advanceNativeE3Homeworld(game, g => g.phase === 7 && nativeE3HomeworldClean(g), setup.actions);
const beforeReveal = structuredClone(game), token = game.discoveries!.tokens.find(t => t.face === face)!.id;
if (viewGame(game, setup.owner).discoveries!.canInspect.includes(token))
game = actNativeE3Homeworld(game, { actor: setup.owner, action: { type: 'discovery', token, reveal: false } }, setup.actions);
game = actNativeE3Homeworld(game, { actor: setup.owner, action: { type: 'discovery', token, reveal: true } }, setup.actions);
const afterReveal = structuredClone(game);
game = advanceNativeE3Homeworld(game, g => g.turn === 2 && !g.response && !g.phaseOpening &&
g.decision?.kind === 'discoveryEntry' && g.decision.player === setup.owner, setup.actions);
const entryWindow = structuredClone(game), view = viewGame(game, setup.owner);
const action = discoveryEntryMoveAction(view, view.discoveryEntry!.sources); assert.ok(action);
const entry: NativeE3HomeworldStep = { actor: setup.owner, action };
game = actNativeE3Homeworld(game, entry, setup.actions);
return { ...setup, beforeShipment, shipment, afterShipment, beforeReveal, afterReveal, token, entryWindow, entry, entered: game };
}
export type NativeE3HomeworldArrival = { before: Game; step: NativeE3HomeworldStep; after: Game };
/** Reusable original human shipment boundary, including noncombatant BG entry. */
export function invadeNativeE3Homeworld(state: Game, actor: string, destination: string,
amount: number): NativeE3HomeworldArrival {
const actions: NativeE3HomeworldStep[] = [], before = movement(state, actor, actions);
const step = invasion(before, actor, destination, amount);
const after = arrive(actNativeE3Homeworld(before, step, actions), actions);
return { before, step, after };
}
@@ -0,0 +1,461 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import type { FactionId } from '../game/catalog';
import { TERRITORIES, territory } from '../game/board';
import { richeseCards } from '../game/richese-cards';
import { nativeShipmentSources } from '../game/homeworld-options';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import { greatMakerRideAction } from '../game/great-maker-options';
import { ownedTech } from '../game/tech-tokens';
import {
advanceNativeHomeworld as advance, createNativeHomeworldModuleSetup as setup,
createNativeHomeworldSukProgramme as sukProgramme, createNativeHomeworldDiscoveryProgramme as discoveryProgramme,
enterNativeHomeworldDiscovery as enter, revealNativeHomeworldDiscovery as reveal,
invadeNativeHomeworld as invade, nativeHomeworldArmy as army, nativeHomeworldClean as clean,
nativeHomeworldInventory as inventory, nativeHomeworldLeader as leader, nativeHomeworldMovement as movement,
nativeHomeworldPlayer as player, openNativeHomeworldBattle as openBattle,
revealNativeHomeworldPlans as plans, settleNativeHomeworldArrival as arrival,
type NativeHomeworldModuleOptions,
} from './fixture-homeworld-native-e1e2-modules';
/** Authorized revision PDF21–23 composes variants; exact original native
* component contracts supply abilities. These programmes choose only fresh
* undealt lotteries/unplayed Spice order. No force, wallet, hand, owner, phase,
* signed frame or casualty is staged. Emperor return rulings remain untouched. */
function reject(game: Game, actor: string, action: Action): void {
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
}
function policies(game: Game, actor: string, relevant: (action: Action) => boolean): void {
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, actor); view.players.find(p => p.id === actor)!.bot = difficulty;
const action = botActions(view).find(relevant); assert.ok(action, `${difficulty} consumes an owned relevant offer`);
const after = applyAction(structuredClone(game), actor, action);
if (after.status === 'playing') inventory(after);
}
}
const e1Rosters: readonly (readonly FactionId[])[] = [
['ixians', 'guild'], ['tleilaxu', 'guild'], ['ixians', 'tleilaxu', 'guild', 'fremen'],
];
const e2Rosters: readonly (readonly FactionId[])[] = [
['choam', 'guild'], ['richese', 'guild'], ['choam', 'richese', 'guild', 'fremen'],
];
void test('original Basic/Advanced E1/E2 single and paired rosters compose either or both modules without changing selected 47/35 decks or private native inventories', () => {
for (const advanced of [false, true]) for (const expansions of [['ix'], ['choam']])
for (const roster of expansions[0] === 'ix' ? e1Rosters : e2Rosters)
for (const [skills, discovery] of [[true, false], [false, true], [true, true]]) {
const f = setup({ advanced, expansions, roster, skills, discovery });
assert.equal(f.afterSetup.leaderSkills?.assignments.length ?? 0, skills ? roster.length : 0);
assert.equal(f.afterSetup.discoveries?.tokens.length ?? 0, discovery ? 8 : 0);
for (const p of f.afterSetup.players) {
assert.equal(p.hand.length, 1);
assert.equal(viewGame(f.afterSetup, p.id).players.find(q => q.id !== p.id)!.hand, undefined);
}
if (roster.includes('tleilaxu')) {
assert.equal(player(f.afterSetup, 'tleilaxu').faceDancers!.length, 3);
assert.equal(new Set(player(f.afterSetup, 'tleilaxu').faceDancers!.map(c => c.leader)).size, 3);
}
if (roster.includes('richese')) {
assert.deepEqual(f.afterSetup.richeseCache!.map(c => c.id).sort(), richeseCards().map(c => c.id).sort());
assert.deepEqual(player(f.afterSetup, 'richese').noField!.tokens.map(t => t.value).sort((a, b) => a - b), [0, 3, 5]);
}
if (roster.includes('choam')) assert.equal(player(f.afterSetup, 'choam').leaders.filter(l => l.id === 'choam-auditor').length, advanced ? 1 : 0);
if (f.offered) policies(f.offered, roster[0], a => a.type === 'leaderSkill');
inventory(f.afterSetup);
}
});
void test('supported original Advanced two-deck native rosters retain Ix-selected deck precedence and optional Tech3+/Stronghold2+ components', () => {
const rosters: readonly (readonly FactionId[])[] = [
['ixians', 'choam', 'guild'], ['tleilaxu', 'richese', 'guild'],
['ixians', 'tleilaxu', 'choam', 'richese', 'guild', 'fremen'],
];
for (const roster of rosters) for (const [skills, discovery] of [[true, false], [false, true], [true, true]]) {
const f = setup({ advanced: true, expansions: ['ix', 'choam'], roster, skills, discovery, tech: true, strongholds: true });
assert.ok(f.afterSetup.techTokens && f.afterSetup.strongholdCards);
assert.equal(f.afterSetup.techTokens!.axlotl.owner, roster.includes('tleilaxu') ? 'tleilaxu' : null);
inventory(f.afterSetup);
}
});
void test('native and visiting trained/normal Ixian Suk preserve physical saved sources and allow only the real residual Cyborg exchange after rescue', () => {
for (const advanced of [false, true]) for (const visitor of [false, true]) for (const band of ['normal', 'skilled'] as const) {
const f = sukProgramme({ advanced, visitor, band, tech: true, strongholds: advanced });
const revealed = plans(f.game, f.plans);
let game = advance(revealed, g => g.decision?.kind === (band === 'normal' ? 'ixSubstitution' : 'sukRescue'));
let before = structuredClone(revealed);
if (band === 'skilled') {
const decision = game.decision; assert.ok(decision?.kind === 'sukRescue');
assert.equal(Math.max(...decision.options.map(o => o.normal + o.elite)), 3);
assert.deepEqual(army(game, f.owner, f.territory), army(f.game, f.owner, f.territory),
'Own casualty selection has not transported or killed a counter yet.');
policies(game, f.owner, a => a.type === 'decision' && a.choice !== undefined);
const choice = decision.options.findIndex(o => o.normal === 0 && o.elite === 1 && o.kept?.kind === 'elite');
assert.ok(choice >= 0);
before = structuredClone(game);
reject(game, f.opponent, { type: 'decision', event: decision.event, choice });
reject(game, f.owner, { type: 'decision', event: 'stale-suk', choice });
reject(game, f.owner, { type: 'decision', event: decision.event, choice,
destinations: { [f.territory]: { normal: 1, elite: 1 } } });
game = applyAction(game, f.owner, { type: 'decision', event: decision.event, choice });
game = advance(game, g => g.decision?.kind === 'ixSubstitution');
}
const nativeBefore = army(before, f.owner, 'homeworld:ixians');
const physical = game.lastBattleContext!.sukRescue!.physical!;
assert.equal(physical.source, visitor ? 'visitor-homeworld' : 'native-homeworld');
assert.deepEqual(physical.saved, { normal: 0, elite: 1 });
assert.deepEqual(physical.tanks, { normal: 0, elite: 2 });
assert.equal(player(game, f.owner).tanks, player(before, f.owner).tanks + 2);
assert.equal(player(game, f.owner).elites!.tanks, player(before, f.owner).elites!.tanks + 2);
if (visitor && band === 'normal') {
assert.deepEqual(physical.destinations, { 'homeworld:ixians': { normal: 0, elite: 1 } });
assert.deepEqual(army(game, f.owner, 'homeworld:ixians'), { ...nativeBefore, elite: nativeBefore.elite + 1 });
} else assert.deepEqual(physical.destinations, {});
assert.deepEqual(game.pendingIxSubstitution!.losses, { [f.territory]: 2 });
const substitution = structuredClone(game), beforeArmy = army(game, f.owner, f.territory);
const action: Action = { type: 'decision', sources: { [f.territory]: 1 }, recover: { [f.territory]: 1 } };
reject(game, f.owner, { ...action, recover: { [f.territory]: 3 } });
reject(game, f.owner, { ...action, sources: { reserves: 1 } });
reject(game, f.owner, { ...action, recover: { 'hidden_mobile_stronghold:0': 1 } });
policies(game, f.owner, a => a.type === 'decision' && (a.decline === true || a.sources !== undefined));
game = applyAction(game, f.owner, action);
game = advance(game, g => !g.pendingIxSubstitution);
assert.deepEqual(army(game, f.owner, f.territory), { normal: beforeArmy.normal - 1, elite: beforeArmy.elite + 1 });
assert.equal(player(game, f.owner).tanks, player(substitution, f.owner).tanks);
assert.equal(player(game, f.owner).elites!.tanks, player(substitution, f.owner).elites!.tanks - 1);
assert.equal(player(game, f.owner).battleLosses, player(substitution, f.owner).battleLosses);
assert.equal(player(game, f.owner).spice, player(substitution, f.owner).spice);
assert.deepEqual(game.players.map(p => [p.id, p.spice]), substitution.players.map(p => [p.id, p.spice]),
'Substitution creates neither a second support payment nor a bounty for any faction.');
assert.deepEqual(player(game, f.owner).hand, player(substitution, f.owner).hand);
assert.deepEqual(game.discard, substitution.discard);
assert.deepEqual(game.leaderSkills!.assignments, substitution.leaderSkills!.assignments);
if (visitor) assert.deepEqual(army(game, f.owner, 'homeworld:ixians'), army(substitution, f.owner, 'homeworld:ixians'));
reject(game, f.owner, action); inventory(game);
game = advance(game, g => !g.battle && !g.pendingIxSubstitution && !g.decision && !g.response && !g.pendingTreacheryDiscard);
assert.equal(game.lastBattleContext!.winner, f.owner);
assert.equal(game.lastBattleContext!.sukRescue!.completed, true);
assert.equal(game.lastBattleContext!.ixSubstitution!.completed, true);
assert.ok(ownedTech(game.techTokens!, f.owner).includes('heighliners'));
inventory(game);
}
});
void test('genuine maximum trained Cyborg rescue opens no exchange for saved casualties and cannot be claimed twice', () => {
const f = sukProgramme({ advanced: true, visitor: true, band: 'skilled', tech: true });
let game = advance(plans(f.game, f.plans), g => g.decision?.kind === 'sukRescue');
const d = game.decision; assert.ok(d?.kind === 'sukRescue');
const choice = d.options.findIndex(o => o.elite === 3 && o.normal === 0 && o.kept?.kind === 'elite'); assert.ok(choice >= 0);
const action: Action = { type: 'decision', event: d.event, choice };
game = applyAction(game, f.owner, action);
assert.equal(player(game, f.owner).elites!.tanks, 0);
assert.deepEqual(army(game, f.owner, f.territory), { normal: 4, elite: 1 });
assert.deepEqual(army(game, f.owner, 'homeworld:ixians'), { normal: 6, elite: 3 });
reject(game, f.owner, action); inventory(game);
});
void test('native E1/E2 Discovery inspection and later typed parent entry move only original shipped counters without borrowing native reserves', () => {
for (const advanced of [false, true]) for (const family of ['ix', 'choam']) for (const skills of [false, true]) {
const options: NativeHomeworldModuleOptions = family === 'ix'
? { roster: ['ixians', 'tleilaxu', 'guild', 'fremen'], expansions: ['ix'] }
: { roster: ['richese', 'choam', 'guild', 'fremen'], expansions: ['choam'], skill: 'planetologist' };
const f = discoveryProgramme({ ...options, advanced, skills, face: 'cistern', tech: true, strongholds: advanced });
const key = `${f.parent}:${f.parentSector}`;
assert.equal(player(f.game, f.owner).forces[key], 2);
assert.equal(player(f.game, f.owner).reserves, player(f.beforeShipment, f.owner).reserves - 2);
const unveiled = reveal(f);
assert.equal(unveiled.discoveries!.tokens.find(t => t.id === f.token)!.revealedTurn, 1);
assert.equal(player(unveiled, f.owner).forces['cistern:0'] ?? 0, 0, 'Reveal never creates an army.');
const window = advance(unveiled, g => g.turn === 2 && g.decision?.kind === 'discoveryEntry' && g.decision.player === f.owner);
policies(window, f.owner, a => a.type === 'decision');
const view = viewGame(window, f.owner), action = discoveryEntryMoveAction(view, view.discoveryEntry!.sources); assert.ok(action);
reject(window, f.owner, { ...action, sources: { [`homeworld:${f.owner}`]: { normal: 2, elite: 0 } } });
const homes = structuredClone(window.homeworlds!.custody), before = structuredClone(player(window, f.owner));
const after = applyAction(window, f.owner, action);
assert.equal(player(after, f.owner).forces[key] ?? 0, 0);
assert.equal(player(after, f.owner).forces['cistern:0'], 2);
assert.equal(player(after, f.owner).elites?.forces['cistern:0'] ?? 0, family === 'ix' ? 1 : 0);
assert.deepEqual(after.homeworlds!.custody, homes);
assert.equal(player(after, f.owner).spice, before.spice);
assert.equal(player(after, f.owner).moved, before.moved);
assert.equal(player(after, f.owner).shipped, before.shipped);
assert.deepEqual(after.leaderSkills, window.leaderSkills); inventory(after);
}
});
void test('original native Discovery Great Maker votes precede a free typed Fremen reserve ride with original Homeworld custody', () => {
for (const advanced of [false, true]) {
const f = discoveryProgramme({ advanced, maker: true, face: 'cistern', tech: true });
let game = advance(f.game, g => g.decision?.kind === 'greatMakerVote');
for (const actor of game.order) {
assert.ok(game.decision?.kind === 'greatMakerVote'); assert.equal(game.decision.player, actor);
policies(game, actor, a => a.type === 'decision');
game = applyAction(game, actor, { type: 'decision', event: game.decision.event, yes: false });
}
assert.ok(game.decision?.kind === 'greatMakerRide'); assert.equal(game.decision.player, 'fremen');
const view = viewGame(game, 'fremen'), destination = view.greatMaker!.ride!.destinations.find(d => d.territory === 'polar_sink'); assert.ok(destination);
policies(game, 'fremen', a => a.type === 'decision');
const elite = advanced ? 1 : 0, action = greatMakerRideAction(view, destination.territory, destination.sector, 2, elite); assert.ok(action);
reject(game, 'fremen', { ...action, territory: 'homeworld:ixians', sector: 0 });
const before = structuredClone(player(game, 'fremen')), training = structuredClone(game.leaderSkills);
game = applyAction(game, 'fremen', action);
assert.equal(player(game, 'fremen').reserves, before.reserves - 2);
assert.equal(player(game, 'fremen').elites!.reserves, before.elites!.reserves - elite);
assert.equal(player(game, 'fremen').forces['polar_sink:0'], (before.forces['polar_sink:0'] ?? 0) + 2);
assert.equal(player(game, 'fremen').spice, before.spice);
assert.deepEqual(game.leaderSkills, training); inventory(game);
}
});
void test('original Richese marker retains its signed token, charges one invoice and materializes only real native reserves at a revealed nested Discovery', () => {
for (const advanced of [false, true]) {
const f = discoveryProgramme({ advanced, expansions: ['choam'], roster: ['richese', 'choam', 'guild', 'fremen'],
collector: 'guild', skill: 'planetologist', face: 'shrine', tech: true, strongholds: advanced });
const entered = enter(f);
let game = movement(entered, 'richese', 2);
const p = player(game, 'richese'), marker = p.noField!.tokens.find(t => t.value === 5)!;
const before = structuredClone(game), invoice: Action = { type: 'ship', territory: 'shrine', sector: 0,
noField: marker.id, event: p.noFieldEvent, allyPayment: 0 };
reject(game, 'richese', { ...invoice, event: 'stale-marker' });
game = arrival(applyAction(game, 'richese', invoice));
assert.equal(player(game, 'richese').reserves, player(before, 'richese').reserves);
assert.equal(player(game, 'richese').forces['shrine:0'] ?? 0, 0);
assert.equal(player(game, 'richese').noField!.deployed!.tokenId, marker.id);
assert.equal(player(before, 'richese').spice - player(game, 'richese').spice, 1);
assert.equal(player(game, 'guild').spice - player(before, 'guild').spice, 1);
reject(game, 'richese', invoice);
const deployed = structuredClone(game), event = player(game, 'richese').noFieldEvent;
game = applyAction(game, 'richese', { type: 'revealNoField', token: marker.id, event });
assert.equal(player(game, 'richese').forces['shrine:0'], 5);
assert.equal(player(game, 'richese').reserves, player(deployed, 'richese').reserves - 5);
assert.equal(player(game, 'richese').spice, player(deployed, 'richese').spice);
assert.equal(player(game, 'guild').spice, player(deployed, 'guild').spice);
assert.equal(player(game, 'richese').noField!.lastShipped, marker.id);
assert.equal(player(game, 'richese').noField!.deployed, null);
inventory(game);
}
});
void test('real repeated original Richese marker shipments cap the final printed five at the four physical reserves left, never inventing a fifth counter', () => {
let game = setup({ advanced: true, skills: false, expansions: ['choam'], roster: ['richese', 'choam', 'guild'] }).afterSetup;
for (const [index, value] of [5, 3, 5, 3, 5].entries()) {
game = movement(game, 'richese', index + 1);
const t = ['sietch_tabr', 'habbanya_ridge_sietch'].map(id => territory(id))
.find(candidate => !candidate.sectors.includes(game.storm)); assert.ok(t);
const sector = t.sectors[0];
const before = structuredClone(player(game, 'richese')), token = before.noField!.tokens.find(t => t.value === value)!;
game = arrival(applyAction(game, 'richese', { type: 'ship', territory: t.id, sector,
noField: token.id, event: before.noFieldEvent, allyPayment: 0 }));
const deployed = structuredClone(player(game, 'richese'));
game = applyAction(game, 'richese', { type: 'revealNoField', token: token.id, event: deployed.noFieldEvent });
const physical = Math.min(value, before.reserves);
assert.equal(player(game, 'richese').forces[`${t.id}:${sector}`], (before.forces[`${t.id}:${sector}`] ?? 0) + physical);
assert.equal(player(game, 'richese').reserves, before.reserves - physical);
assert.equal(player(game, 'richese').spice, deployed.spice);
if (index === 4) { assert.equal(physical, 4); assert.equal(player(game, 'richese').reserves, 0); }
inventory(game);
}
});
void test('real CHOAM low-home shipment and Advanced paid battle support charge once without bypassing original income or reserve pools', () => {
const f = setup({ advanced: true, expansions: ['choam'], roster: ['choam', 'richese', 'guild', 'fremen'],
skill: 'smuggler', tech: true, strongholds: true });
let game = advance(f.afterSetup, g => g.turn === 1 && g.phase === 5 && clean(g));
const destination = TERRITORIES.find(t => t.type === 'stronghold' && t.id !== 'tueks_sietch' &&
!t.sectors.includes(game.storm) && t.id !== 'sietch_tabr'); assert.ok(destination);
const remaining = new Set(['choam', 'guild']);
while (remaining.size) {
game = advance(game, g => g.turn === 1 && g.phase === 5 && clean(g) && remaining.has(g.active!));
const actor = game.active!, amount = actor === 'choam' ? 10 : 2;
const before = structuredClone(game), sources = nativeShipmentSources(viewGame(game, actor), amount, 0); assert.ok(sources);
game = arrival(applyAction(game, actor, { type: 'ship', territory: destination.id, sector: destination.sectors[0],
amount, elite: 0, allyPayment: 0, homeworldSources: sources }));
if (actor === 'choam') {
assert.equal(player(game, actor).reserves, 10);
assert.equal(player(before, actor).spice - player(game, actor).spice, 10);
assert.equal(player(game, 'guild').spice - player(before, 'guild').spice, 10);
assert.deepEqual(army(game, actor, 'homeworld:choam'), { normal: 10, elite: 0 });
}
remaining.delete(actor);
if (remaining.size) game = applyAction(game, actor, { type: 'endMovement' });
}
game = openBattle(game, 'choam', 'guild', destination.id, '');
const beforePlans = structuredClone(game);
game = plans(game, [{ actor: 'choam', action: { type: 'battlePlan', leader: leader(game, 'choam', true), dial: 1, support: 1 } },
{ actor: 'guild', action: { type: 'battlePlan', leader: leader(game, 'guild'), dial: 0, support: 0 } }]);
game = advance(game, g => !g.battle && clean(g) && !g.pendingTreacheryDiscard);
assert.equal(game.lastBattleContext!.winner, 'choam');
assert.equal(player(game, 'choam').tanks, player(beforePlans, 'choam').tanks + 1);
assert.equal(player(game, 'choam').forces[`${destination.id}:${destination.sectors[0]}`], 9);
assert.equal(player(game, 'choam').leaders.some(l => l.id === 'choam-auditor'), true);
inventory(game);
});
void test('original Tleilaxu native death returns training once, real revival offers native discs only, and actual foreign ghola pays without moving the physical owner', () => {
for (const nativeTrainer of [false, true]) {
const f = setup({ advanced: true, expansions: ['ix'], roster: ['tleilaxu', 'guild', 'fremen'],
skill: 'prana-bindu-adept', startingKinds: ['projectile', 'projectile', 'worthless'], tech: true });
let game = movement(f.afterSetup, 'guild');
game = invade(game, 'guild', 'homeworld:tleilaxu', 3);
game = openBattle(game, 'tleilaxu', 'guild', 'homeworld:tleilaxu', nativeTrainer ? 'tleilaxu' : '');
const trained = game.leaderSkills!.assignments.find(a => a.owner === 'tleilaxu')!;
const own = nativeTrainer ? trained.leader : leader(game, 'tleilaxu', true), foreign = leader(game, 'guild');
const ownWeapon = player(game, 'tleilaxu').hand.find(c => c.kind === 'projectile')!.id;
const foreignWeapon = player(game, 'guild').hand.find(c => c.kind === 'projectile')!.id;
game = plans(game, [{ actor: 'tleilaxu', action: { type: 'battlePlan', leader: own, dial: 1, support: 1, weapon: ownWeapon } },
{ actor: 'guild', action: { type: 'battlePlan', leader: foreign, dial: 0, support: 0, weapon: foreignWeapon } }]);
game = advance(game, g => !g.battle && clean(g) && !g.pendingTreacheryDiscard);
assert.equal(player(game, 'tleilaxu').leaders.find(l => l.id === own)!.dead, true);
assert.equal(player(game, 'guild').leaders.find(l => l.id === foreign)!.dead, true);
assert.equal(game.leaderSkills!.assignments.some(a => a.leader === trained.leader), !nativeTrainer);
game = advance(game, g => g.turn === 2 && g.phase === 4 && clean(g));
if (nativeTrainer) {
const before = structuredClone(player(game, 'tleilaxu'));
game = applyAction(game, 'tleilaxu', { type: 'reviveLeader', leader: own });
game = advance(game, g => !!g.leaderSkills!.offers.tleilaxu && !g.response);
assert.equal(player(game, 'tleilaxu').leaders.find(l => l.id === own)!.dead, false);
assert.ok(player(game, 'tleilaxu').spice < before.spice);
policies(game, 'tleilaxu', a => a.type === 'leaderSkill');
assert.ok(viewGame(game, 'tleilaxu').leaderSkills!.eligibleLeaders.some(l => l.id === own));
} else {
const before = structuredClone(game);
reject(game, 'guild', { type: 'reviveForeignGhola', leader: foreign });
game = applyAction(game, 'tleilaxu', { type: 'reviveForeignGhola', leader: foreign });
game = advance(game, g => !g.pendingRevival && !g.response && !g.decision);
const ghola = player(game, 'guild').leaders.find(l => l.id === foreign)!;
assert.equal(ghola.dead, false); assert.equal(ghola.gholaBy, 'tleilaxu');
assert.equal(player(game, 'tleilaxu').leaders.some(l => l.id === foreign), false);
assert.ok(player(game, 'tleilaxu').spice < player(before, 'tleilaxu').spice);
assert.deepEqual(game.leaderSkills, before.leaderSkills);
assert.ok(!viewGame(game, 'tleilaxu').leaderSkills!.eligibleLeaders.some(l => l.id === foreign));
assert.deepEqual(game.homeworlds!.custody, before.homeworlds!.custody);
}
inventory(game);
}
});
void test('actual native Homeworld forbids a visiting Tleilaxu Face Dance even with the genuinely drawn matching trainer identity', () => {
const f = sukProgramme({ advanced: true, visitor: false, band: 'skilled', tech: true });
assert.ok(player(f.afterSetup, 'tleilaxu').faceDancers!.some(c => c.leader === f.trainer && !c.revealed));
let game = advance(plans(f.game, f.plans), g => g.decision?.kind === 'sukRescue');
const d = game.decision; assert.ok(d?.kind === 'sukRescue');
game = applyAction(game, f.owner, { type: 'decision', event: d.event, choice: 0 });
game = advance(game, g => !g.battle && clean(g) && g.phase === 7);
assert.notEqual(game.decision?.kind, 'faceDance');
assert.equal(player(game, 'tleilaxu').faceDancers!.find(c => c.leader === f.trainer)!.revealed, false);
const protectedArmy = army(game, f.owner, f.territory);
reject(game, 'tleilaxu', { type: 'decision', reveal: true, sources: { reserves: 1 }, sector: 0 });
assert.equal(player(game, f.owner).leaders.find(l => l.id === f.trainer)!.dead, false);
assert.deepEqual(army(game, f.owner, f.territory), protectedArmy);
inventory(game);
});
void test('genuine Shrine armies retain earned Stronghold ownership and complete original card cleanup and Tech reward before a matching Face Dance', () => {
const f = discoveryProgramme({ advanced: true, face: 'shrine', tech: true, strongholds: true });
let game = movement(enter(f), 'tleilaxu', 2);
const sources = nativeShipmentSources(viewGame(game, 'tleilaxu'), 2, 0); assert.ok(sources);
game = arrival(applyAction(game, 'tleilaxu', { type: 'ship', territory: 'shrine', sector: 0,
amount: 2, elite: 0, allyPayment: 0, homeworldSources: sources }));
game = openBattle(game, 'ixians', 'tleilaxu', 'shrine');
const trainer = game.leaderSkills!.assignments.find(a => a.owner === 'ixians')!.leader;
assert.ok(player(game, 'tleilaxu').faceDancers!.some(c => c.leader === trainer && !c.revealed));
assert.equal(game.strongholdCards!.owners.hidden_mobile_stronghold, 'ixians');
const weapon = player(game, 'ixians').hand.find(c => c.kind === 'worthless')!.id;
game = plans(game, [{ actor: 'ixians', action: { type: 'battlePlan', leader: trainer, dial: 0, support: 0, weapon } },
{ actor: 'tleilaxu', action: { type: 'battlePlan', leader: leader(game, 'tleilaxu'), dial: 0, support: 0 } }]);
game = advance(game, g => g.decision?.kind === 'battleCards');
assert.equal(game.techTokens!.axlotl.owner, 'tleilaxu', 'The actual card cleanup still precedes the reward.');
policies(game, 'ixians', a => a.type === 'decision' && a.discard !== undefined);
game = applyAction(game, 'ixians', { type: 'decision', discard: [] });
game = advance(game, g => g.decision?.kind === 'faceDance');
assert.equal(game.techTokens!.axlotl.owner, 'ixians');
const before = structuredClone(game), prior = structuredClone(player(game, 'ixians'));
const action: Action = { type: 'decision', reveal: true, sources: { reserves: 1 }, sector: 0 };
reject(game, 'ixians', action);
reject(game, 'tleilaxu', { ...action, sources: { reserves: 3 } });
policies(game, 'tleilaxu', a => a.type === 'decision' && a.reveal !== undefined);
game = applyAction(game, 'tleilaxu', action);
assert.equal(player(game, 'ixians').forces['shrine:0'] ?? 0, 0);
assert.equal(player(game, 'ixians').reserves, prior.reserves + 2);
assert.equal(player(game, 'ixians').elites!.reserves, prior.elites!.reserves + 1);
assert.equal(player(game, 'tleilaxu').forces['shrine:0'], 1);
assert.equal(player(game, 'tleilaxu').reserves, player(before, 'tleilaxu').reserves - 1);
assert.equal(player(game, 'ixians').leaders.find(l => l.id === trainer)!.dead, true);
assert.equal(game.leaderSkills!.assignments.some(a => a.leader === trainer), false);
assert.equal(game.leaderSkills!.deck.filter(id => id === 'suk-graduate').length, 1);
assert.deepEqual(game.techTokens, before.techTokens);
assert.deepEqual(player(game, 'ixians').hand, prior.hand);
assert.equal(player(game, 'ixians').spice, prior.spice);
assert.equal(player(game, 'ixians').tanks, prior.tanks);
assert.equal(player(game, 'tleilaxu').spice, player(before, 'tleilaxu').spice);
assert.equal(player(game, 'tleilaxu').faceDancers!.find(c => c.leader === trainer)!.revealed, true);
inventory(game);
});
void test('original trained Richese ordinary arrival retains one nested invoice and original marker custody', () => {
const f = discoveryProgramme({ advanced: true, expansions: ['choam'], roster: ['richese', 'choam', 'guild', 'fremen'],
collector: 'richese', face: 'cistern', skill: 'planetologist', tech: true, strongholds: true });
let game = movement(enter(f), 'richese', 2);
const before = structuredClone(game), sources = nativeShipmentSources(viewGame(game, 'richese'), 1, 0); assert.ok(sources);
assert.equal(player(game, 'richese').forces['cistern:0'], 2);
const action: Action = { type: 'ship', territory: 'cistern', sector: 0, amount: 1, elite: 0,
allyPayment: 0, homeworldSources: sources };
game = arrival(applyAction(game, 'richese', action));
assert.equal(player(game, 'richese').forces['cistern:0'], 3);
assert.equal(player(game, 'richese').reserves, player(before, 'richese').reserves - 1);
assert.equal(player(game, 'richese').spice, player(before, 'richese').spice - 1);
assert.equal(player(game, 'guild').spice, player(before, 'guild').spice + 1);
assert.equal(player(game, 'richese').noField!.deployed, null);
assert.deepEqual(player(game, 'richese').noField!.tokens, player(before, 'richese').noField!.tokens);
reject(game, 'richese', action); inventory(game);
});
void test('actually earned Arrakeen support subsidy applies only at its held source, not a low native CHOAM Homeworld battle', () => {
const balanceChanges: number[] = [];
for (const nativeBattle of [false, true]) {
const f = setup({ advanced: true, expansions: ['choam'], roster: ['choam', 'richese', 'guild', 'fremen'],
skill: 'smuggler', tech: true, strongholds: true });
let game = movement(f.afterSetup, 'choam');
const sources = nativeShipmentSources(viewGame(game, 'choam'), 8, 0); assert.ok(sources);
game = arrival(applyAction(game, 'choam', { type: 'ship', territory: 'arrakeen', sector: 10,
amount: 8, elite: 0, smuggler: true, allyPayment: 0, homeworldSources: sources }));
assert.equal(game.strongholdCards!.owners.arrakeen, null);
game = advance(game, g => g.turn === 2 && g.phase === 5 && clean(g));
assert.equal(game.strongholdCards!.owners.arrakeen, 'choam');
assert.equal(game.strongholdCards!.claimedTurn, 1, 'Only original end-Mentat claims the physical card.');
const destination = nativeBattle ? 'homeworld:choam' : 'arrakeen';
const remaining = new Set(['choam', 'guild']);
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 === 'choam') {
const outward = nativeShipmentSources(viewGame(game, actor), 3, 0); assert.ok(outward);
game = arrival(applyAction(game, actor, { type: 'ship', territory: 'carthag', sector: 11,
amount: 3, elite: 0, smuggler: true, allyPayment: 0, homeworldSources: outward }));
assert.equal(player(game, actor).reserves, 9);
} else if (nativeBattle) game = invade(game, actor, destination, 2);
else {
const enemy = nativeShipmentSources(viewGame(game, actor), 2, 0); assert.ok(enemy);
game = arrival(applyAction(game, actor, { type: 'ship', territory: destination, sector: 10,
amount: 2, elite: 0, allyPayment: 0, homeworldSources: enemy }));
}
remaining.delete(actor);
if (remaining.size) game = applyAction(game, actor, { type: 'endMovement' });
}
game = openBattle(game, 'choam', 'guild', destination, '');
const before = structuredClone(game);
game = plans(game, [{ actor: 'choam', action: { type: 'battlePlan', leader: leader(game, 'choam', true), dial: 1, support: 1 } },
{ actor: 'guild', action: { type: 'battlePlan', leader: leader(game, 'guild'), dial: 0, support: 0 } }]);
game = advance(game, g => !g.battle && clean(g) && !g.pendingTreacheryDiscard);
assert.equal(game.lastBattleContext!.winner, 'choam');
balanceChanges.push(player(game, 'choam').spice - player(before, 'choam').spice);
assert.equal(player(game, 'choam').tanks, player(before, 'choam').tanks + 1);
assert.equal(game.strongholdCards!.owners.arrakeen, 'choam');
assert.equal(player(game, 'choam').forces['arrakeen:10'], nativeBattle ? 8 : 7);
inventory(game);
}
assert.equal(balanceChanges[0] - balanceChanges[1], 1, 'Only the held Arrakeen source subsidizes support; identical later stronghold income is independent.');
});
@@ -0,0 +1,267 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
applyAction, createGame, initializeDiscoveryGameForAudit, initializeLeaderSkillsGameForAudit,
joinGame, newPlayer, viewGame, type Action, type Game,
} from '../game/engine';
import { DIFFICULTIES } from '../game/bot-profiles';
import type { FactionId } from '../game/catalog';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { ecazOccupancyRelation } from '../game/ecaz-occupy';
import { ownedTech } from '../game/tech-tokens';
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
import {
advanceNativeE3Homeworld as advance, assertNativeE3HomeworldCustody as custody,
createNativeE3HomeworldBattle as battle, createNativeE3HomeworldDiscovery as discovery,
createNativeE3HomeworldRevival as revival, createNativeE3HomeworldSetup as setup,
invadeNativeE3Homeworld as invade,
nativeE3HomeworldClean as clean, nativeE3HomeworldPlayer as player,
nativeE3HomeworldPolicy as policy, nativeE3HomeworldPool as pool,
rescueNativeE3Homeworld as rescue, revealNativeE3HomeworldPlans as reveal,
settleNativeE3HomeworldBattle as settle, type NativeE3HomeworldOptions,
} from './fixture-homeworld-native-e3-modules';
function legalPolicies(game: Game, actor: string): void {
for (const difficulty of DIFFICULTIES) {
const actions = policy(game, actor, difficulty); assert.ok(actions.length, `${difficulty} has an actual owned offer`);
for (const action of actions) {
const next = applyAction(structuredClone(game), actor, action);
if (next.homeworlds?.custody) custody(next);
}
}
}
function reject(game: Game, actor: string, action: Action, reason?: RegExp): void {
const before = structuredClone(game);
if (reason) assert.throws(() => applyAction(game, actor, action), reason);
else assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
void test('original standalone Ecaz or Moritani Homeworld module envelopes use native Ecaz33, five discs, all14 before faction setup and DS7+8 at supported seat boundaries', () => {
const modules: Pick<NativeE3HomeworldOptions, 'skills' | 'discovery'>[] = [
{ skills: true, discovery: false }, { skills: false, discovery: true }, { skills: true, discovery: true },
];
for (const faction of ['ecaz', 'moritani'] as const) for (const advanced of [false, true]) {
for (const composition of modules) for (const tech of [false, true]) for (const strongholds of advanced ? [false, true] : [false]) {
for (const count of [tech ? 3 : 2, 6]) {
const roster: FactionId[] = [faction, 'guild', 'emperor', 'atreides', 'fremen', 'beneGesserit'];
const lobby = createGame('ORIGINALNATIVEHWE3', newPlayer('native-original', 'Original native', faction), advanced, ['ecaz']);
for (const [i, f] of roster.slice(1, count).entries()) joinGame(lobby, newPlayer(`original-${i}`, `Original ${f}`, f));
const f = setup({ initial: lobby, faction, advanced, ...composition, tech, strongholds });
const own = player(f.afterSetup, f.owner);
assert.equal(own.reserves, 14); assert.equal(Object.values(own.forces).reduce((a, b) => a + b, 0), 6);
assert.equal(pool(f.afterSetup, f.owner, `homeworld:${faction}`).normal, 14);
assert.equal(own.leaders.length, 5); assert.equal(f.afterSetup.dukeVidal!.leader.id, DUKE_VIDAL_ID);
if (composition.skills) {
assert.equal(f.afterSetup.leaderSkills!.assignments.length, count);
assert.deepEqual([...f.afterSetup.leaderSkills!.deck, ...f.afterSetup.leaderSkills!.assignments.map(a => a.skill)].sort(),
LEADER_SKILL_CARDS.map(c => c.id).sort());
} else assert.equal(f.afterSetup.leaderSkills, undefined);
if (composition.discovery) {
const spice = [...f.afterSetup.spiceDeck, ...f.afterSetup.spiceDiscard.flat()];
assert.equal(spice.filter(c => 'territory' in c && !!c.discovery).length, 6);
assert.equal(spice.filter(c => 'worm' in c && c.greatMaker).length, 1);
assert.equal(f.afterSetup.discoveries!.tokens.length, 8);
} else assert.equal(f.afterSetup.discoveries, undefined);
if (strongholds) assert.ok(Object.values(f.afterSetup.strongholdCards!.owners).every(o => o === null));
assert.equal(!!f.afterSetup.moritaniAssassinate, advanced && faction === 'moritani');
if (faction === 'ecaz') {
assert.equal(f.afterSetup.ecazAmbassadors!.tokens.length, 11);
assert.equal(f.afterSetup.ecazAmbassadors!.tokens.filter(t => t.zone === 'supply').length, 6);
} else assert.equal(f.afterSetup.moritaniTerror!.tokens.length, 6);
custody(f.afterSetup);
}
}
}
});
for (const faction of ['ecaz', 'moritani'] as const) for (const advanced of [false, true]) {
void test(`${advanced ? 'Advanced' : 'Basic'} ${faction} native and visitor Suk consume real casualties and preserve owner-labelled native/visitor pools`, () => {
for (const visitor of [false, true]) {
const f = battle({ faction, advanced, visitor, skills: true, discovery: true, tech: true, strongholds: advanced });
const shipper = visitor ? f.owner : f.opponent, native = visitor ? f.opponent : f.owner;
assert.equal(player(f.afterShipment, shipper).reserves, player(f.beforeShipment, shipper).reserves - 5);
assert.equal(pool(f.afterShipment, shipper, f.location).normal, 5);
assert.deepEqual(pool(f.afterShipment, native, f.location), pool(f.beforeShipment, native, f.location));
assert.ok(player(f.afterShipment, shipper).spice < player(f.beforeShipment, shipper).spice);
assert.ok(!Object.keys(player(f.afterShipment, shipper).forces).some(key => key.startsWith('homeworld:')));
legalPolicies(f.beforeShipment, shipper); legalPolicies(f.game, f.owner);
const revealed = reveal(f.game, f.plans), pending = settle(revealed, 'suk');
assert.ok(pending.decision?.kind === 'sukRescue');
assert.equal(pending.decision.player, f.owner); assert.equal(pending.pendingSukRescue!.skill.mode, 'skilled');
assert.equal(pending.pendingSukRescue!.territory, f.location);
legalPolicies(pending, f.owner);
const beforePool = pool(pending, f.owner, f.location), beforeReserve = player(pending, f.owner).reserves;
const beforeTanks = player(pending, f.owner).tanks;
const maximum = Math.max(...pending.decision.options.map(o => o.normal + o.elite));
const choice = pending.decision.options.findIndex(o => o.normal + o.elite === maximum && o.kept?.kind === 'normal');
const option = pending.decision.options[choice], losses = pending.pendingSukRescue!.losses!;
reject(pending, f.owner, { type: 'decision', event: pending.decision.event, choice,
destinations: { 'homeworld:emperor': { normal: option.normal, elite: 0 } } });
const done = settle(rescue(structuredClone(pending)));
assert.equal(done.lastBattleContext?.winner, f.owner);
assert.equal(player(done, f.owner).tanks - beforeTanks, losses.normal + losses.elite - option.normal - option.elite);
const afterPool = pool(done, f.owner, f.location);
if (visitor) {
assert.equal(afterPool.normal, beforePool.normal - losses.normal + 1);
assert.equal(player(done, f.owner).reserves, beforeReserve + option.normal - 1);
assert.equal(pool(done, f.owner, `homeworld:${faction}`).normal, player(done, f.owner).reserves);
} else assert.equal(afterPool.normal, beforePool.normal - losses.normal + option.normal);
assert.equal(done.pendingSukRescue ?? null, null); custody(done);
}
});
void test(`${advanced ? 'Advanced' : 'Basic'} ${faction} lethal native and visitor Homeworld plans kill the trained disc and return exactly one physical skill`, () => {
for (const visitor of [false, true]) {
const f = battle({ faction, advanced, visitor, lethal: true, skill: 'mentat', discovery: true });
const original = player(f.game, f.owner).leaders.find(l => l.id === f.trainer)!;
const done = settle(reveal(f.game, f.plans));
assert.equal(player(done, f.owner).leaders.find(l => l.id === f.trainer)!.dead, true);
assert.equal(player(done, f.owner).leaders.find(l => l.id === f.trainer)!.deaths, original.deaths + 1);
assert.equal(done.leaderSkills!.assignments.some(a => a.leader === f.trainer), false);
assert.equal(done.leaderSkills!.deck.filter(s => s === 'mentat').length, 1);
const window = advance(done, g => g.turn === 2 && g.phase === 4 && clean(g));
reject(window, f.owner, { type: 'reviveLeader', leader: f.trainer }, /All leaders must die/);
assert.equal(window.leaderSkills!.deck.filter(s => s === 'mentat').length, 1); custody(window);
}
});
void test(`${advanced ? 'Advanced' : 'Basic'} ${faction} original parent shipment, reveal and later Discovery entry retain native reserves and printed free-entry payment`, () => {
for (const skills of [false, true]) {
const f = discovery({ faction, advanced, skills, tech: true, strongholds: advanced });
assert.equal(player(f.afterShipment, f.owner).reserves, player(f.beforeShipment, f.owner).reserves - 2);
assert.ok(player(f.afterShipment, f.owner).spice < player(f.beforeShipment, f.owner).spice);
assert.equal(player(f.beforeReveal, f.owner).forces['gara_kulon:8'], 2);
assert.equal(f.afterReveal.discoveries!.tokens.find(t => t.id === f.token)!.revealedTurn, 1);
assert.equal(player(f.entered, f.owner).forces['gara_kulon:8'] ?? 0, 0);
assert.equal(player(f.entered, f.owner).forces['cistern:0'], 2);
assert.equal(player(f.entered, f.owner).spice, player(f.entryWindow, f.owner).spice);
assert.equal(player(f.entered, f.owner).reserves, player(f.entryWindow, f.owner).reserves);
assert.deepEqual(f.entered.homeworlds!.custody, f.entryWindow.homeworlds!.custody);
legalPolicies(f.entryWindow, f.owner); custody(f.entered);
}
});
void test(`${advanced ? 'Advanced' : 'Basic'} ${faction} five paid original visitor invasions establish all-five deaths before one real own revival and private replacement training`, () => {
const f = revival({ faction, advanced, discovery: true, tech: true, strongholds: advanced });
assert.equal(new Set(f.deaths.map(d => d.leader)).size, 5);
for (const [i, death] of f.deaths.entries()) {
assert.equal(player(death.after, f.owner).leaders.filter(l => l.dead).length, i + 1);
assert.equal(player(death.after, f.owner).leaders.find(l => l.id === death.leader)!.deaths,
player(death.before, f.owner).leaders.find(l => l.id === death.leader)!.deaths + 1);
assert.equal(death.after.lastBattleContext?.winner, f.opponent);
assert.equal(player(death.after, f.owner).tanks, player(death.before, f.owner).tanks + 1);
assert.equal(pool(death.after, f.owner, 'homeworld:guild').normal, 0);
assert.equal(player(death.after, f.opponent).hand.filter(c => c.id === death.weapon).length, 1);
assert.equal(death.after.discard.some(c => c.id === death.weapon), false); custody(death.after);
}
const trainer = player(f.revivalWindow, f.owner).leaders.find(l => l.id === f.trainer)!;
assert.equal(player(f.offeredRevival, f.owner).spice, player(f.revivalWindow, f.owner).spice - trainer.strength);
assert.equal(player(f.offeredRevival, f.owner).leaders.find(l => l.id === f.trainer)!.dead, false);
assert.equal(player(f.offeredRevival, f.owner).leaders.filter(l => l.dead).length, 4);
assert.equal(viewGame(f.offeredRevival, f.opponent).leaderSkills!.offer, null);
assert.deepEqual(viewGame(f.offeredRevival, f.owner).leaderSkills!.offer!.cards, []);
legalPolicies(f.offeredRevival, f.owner);
const event = f.offeredRevival.leaderSkills!.offers[f.owner].event;
const drawn = applyAction(structuredClone(f.offeredRevival), f.owner, { type: 'leaderSkill', event, mode: 'draw' });
const own = viewGame(drawn, f.owner).leaderSkills!, skill = own.offer!.cards.find(s => !own.unavailableSkills?.[s]);
assert.ok(skill); assert.equal(own.offer!.cards.length, 2);
const assigned = applyAction(drawn, f.owner, { type: 'leaderSkill', event, skill, leader: f.trainer });
assert.equal(assigned.leaderSkills!.assignments.find(a => a.owner === f.owner)!.leader, f.trainer);
assert.equal(player(assigned, f.owner).spice, player(f.offeredRevival, f.owner).spice); custody(assigned);
});
}
void test('Advanced native Moritani resolves Guild Suk BEFORE assassination, returns the exact trainer card, pays its printed bounty, then draws one private Mentat replacement', () => {
const f = battle({ faction: 'moritani', advanced: true, assassination: true, discovery: true,
tech: true, strongholds: true, battleTurn: 2 });
assert.equal(f.game.strongholdCards!.owners.tueks_sietch, f.opponent);
const revealed = reveal(f.game, f.plans), pending = settle(revealed, 'assassination');
assert.deepEqual(pending.lastBattleContext!.sukRescue!.physical!.saved, { normal: 1, elite: 0 });
assert.equal(pending.lastBattleContext?.sukRescue?.completed, true);
assert.equal(player(pending, f.opponent).spice, player(revealed, f.opponent).spice - 3,
'Holding Tuek never applies its Worthless income to a Homeworld battle.');
const target = pending.leaderSkills!.assignments.find(a => a.owner === f.opponent)!.leader;
const leader = player(pending, f.opponent).leaders.find(l => l.id === target)!;
assert.equal(leader.name, 'Master Bewt'); assert.ok(player(pending, f.owner).traitors.includes(target));
assert.notEqual(revealed.battle!.plans[f.opponent].leader, target);
const reserve = [...pending.traitorReserve!], ownTraitors = [...player(pending, f.owner).traitors];
legalPolicies(pending, f.owner);
const decision = pending.decision; assert.ok(decision?.kind === 'moritaniAssassinate');
let game = applyAction(structuredClone(pending), f.owner, { type: 'decision', event: decision.event, card: target });
assert.equal(player(game, f.opponent).leaders.find(l => l.id === target)!.dead, true);
assert.equal(player(game, f.owner).spice, player(pending, f.owner).spice + leader.strength);
assert.equal(game.leaderSkills!.assignments.some(a => a.leader === target), false);
assert.equal(game.leaderSkills!.deck.filter(s => s === 'suk-graduate').length, 1);
assert.equal(player(game, f.opponent).tanks, player(pending, f.opponent).tanks);
assert.deepEqual(game.homeworlds!.custody, pending.homeworlds!.custody);
const lost = ownedTech(pending.techTokens, f.owner); assert.ok(lost.length);
game = settle(game, 'tech');
let token = lost[0];
if (game.decision?.kind === 'techToken') {
assert.equal(game.decision.player, f.opponent);
legalPolicies(game, f.opponent);
reject(game, f.opponent, { type: 'decision', decline: true });
token = game.decision.choices[0];
game = applyAction(game, f.opponent, { type: 'decision', token });
game = settle(game);
}
assert.equal(game.techTokens![token].owner, f.opponent);
assert.equal(player(game, f.opponent).tanks, player(pending, f.opponent).tanks);
game = advance(game, g => g.phase === 8);
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.stage, 'replaced');
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.replacement, reserve[0]);
assert.deepEqual(game.traitorReserve, reserve.slice(1));
assert.deepEqual(player(game, f.owner).traitors, [...ownTraitors.filter(c => c !== target), reserve[0]]);
assert.equal('replacement' in viewGame(game, f.opponent).moritaniAssassinate!.history.at(-1)!, false);
game = advance(game, g => g.turn === 3 && g.phase === 4 && clean(g));
reject(game, f.opponent, { type: 'reviveLeader', leader: target }, /All leaders must die/); custody(game);
});
void test('Basic native Moritani never acquires Advanced assassination after the same original Homeworld loss and normal Guild rescue', () => {
const f = battle({ faction: 'moritani', advanced: false, assassination: true, discovery: true, tech: true });
const game = settle(reveal(f.game, f.plans));
const target = game.leaderSkills!.assignments.find(a => a.owner === f.opponent)!.leader;
assert.deepEqual(game.lastBattleContext!.sukRescue!.physical!.saved, { normal: 1, elite: 0 });
assert.equal(game.lastBattleContext?.winner, f.opponent); assert.equal(game.moritaniAssassinate, undefined);
assert.equal(player(game, f.opponent).leaders.find(l => l.id === target)!.dead, false);
assert.equal(game.leaderSkills!.assignments.find(a => a.leader === target)!.skill, 'suk-graduate'); custody(game);
});
void test('Homeworlds remain distinct seats rather than Ecaz Occupy territories, and Duke/Harkonnen/E3 pair exclusions remain original guards', () => {
const seats = [{ id: 'ecaz', faction: 'ecaz' as const, ally: 'guild' },
{ id: 'guild', faction: 'guild' as const, ally: 'ecaz' }, { id: 'bg', faction: 'beneGesserit' as const, ally: null }];
assert.equal(ecazOccupancyRelation(seats, 'ecaz', 'guild', { kind: 'territory', id: 'arrakeen' }), 'ecazAlliance');
assert.equal(ecazOccupancyRelation(seats, 'ecaz', 'guild', { kind: 'homeworld', id: 'homeworld:ecaz' }), 'different');
const f = setup({ faction: 'ecaz', advanced: true });
legalPolicies(f.offered, f.owner);
const offer = viewGame(f.offered, f.owner).leaderSkills!.offer!;
reject(f.offered, f.owner, { type: 'leaderSkill', event: offer.event, skill: offer.cards[0], leader: DUKE_VIDAL_ID });
for (const roster of [['ecaz', 'moritani', 'guild'], ['ecaz', 'harkonnen', 'guild'], ['moritani', 'harkonnen', 'guild']] as FactionId[][]) {
let game = createGame('NATIVEHWGUARDS', newPlayer(roster[0], roster[0], roster[0]), true, ['ecaz']);
for (const faction of roster.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
game = applyAction(game, game.host, { type: 'homeworlds', enabled: true }); game.discoveryEnabled = true;
for (const p of game.players) game = applyAction(game, p.id, { type: 'ready' });
const before = structuredClone(game);
assert.throws(() => initializeLeaderSkillsGameForAudit(game)); assert.deepEqual(game, before);
if (roster[0] !== 'ecaz' || roster[1] !== 'harkonnen')
assert.throws(() => initializeDiscoveryGameForAudit(game));
assert.deepEqual(game, before);
assert.equal(game.homeworlds!.custody, null);
}
const basic = setup({ faction: 'ecaz', advanced: false, roster: ['ecaz', 'harkonnen', 'guild'], discovery: true });
assert.equal(player(basic.afterSetup, 'harkonnen').hand.length, 2); custody(basic.afterSetup);
});
void test('actual Ecaz arrival at native Wallach IX never opens BG Intrusion or a territory Occupy battle', () => {
const f = setup({ faction: 'ecaz', advanced: true, roster: ['ecaz', 'guild', 'beneGesserit'], discovery: true });
const arrival = invade(f.afterSetup, f.owner, 'homeworld:beneGesserit', 3);
assert.equal(pool(arrival.after, f.owner, 'homeworld:beneGesserit').normal, 3);
assert.deepEqual(pool(arrival.after, 'beneGesserit', 'homeworld:beneGesserit'),
pool(arrival.before, 'beneGesserit', 'homeworld:beneGesserit'));
assert.equal(arrival.after.decision, null);
assert.equal(arrival.after.pendingAmbassador ?? null, null);
assert.ok(!Object.keys(player(arrival.after, 'beneGesserit').advisors ?? {}).some(key => key.startsWith('homeworld:')));
assert.ok(!Object.keys(player(arrival.after, f.owner).forces).some(key => key.startsWith('homeworld:')));
custody(arrival.after);
});