Compose native Homeworld Nexus optional modules

This commit is contained in:
Quintine committed 2026-10-07 08:54:53 +11:00
1 parent 71bc325289
commit 2efa10a9d6
19 files changed
+1961 -62

No files matched your search

+373
View File
@@ -0,0 +1,373 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeLeaderSkillsGameForAudit, initializePairedNexusGameForAudit,
joinGame, newPlayer, viewGame, type Action, type Game,
} from '../game/engine';
import { territory } from '../game/board';
import { treacheryDeck, type Card } from '../game/cards';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
import { richeseCards } from '../game/richese-cards';
import { traitorDeck } from '../game/traitors';
import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { DISCOVERY_CARD_PLACEMENTS } from '../game/discoveries';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import { greatMakerRideAction } from '../game/great-maker-options';
import {
nativeHomeworldArmy, nativeHomeworldClean, nativeHomeworldInventory, nativeHomeworldLeader,
nativeHomeworldPlayer, nextNativeHomeworldModuleStep, frontNativeHomeworldSpice,
} from './fixture-homeworld-native-e1e2-modules';
import { nextDiscoveryPairedNexusE1SkillsStep } from './fixture-discovery-paired-nexus-e1-skills';
import { nextDiscoveryPairedNexusE2SkillsStep } from './fixture-discovery-paired-nexus-e2-skills';
import { nextPairedIxNexusModulesStep } from './fixture-paired-ix-nexus-modules';
import { nextPairedChoamStep } from './fixture-paired-choam-nexus-modules';
import { withClassicDiscoveryNexusLottery, withClassicDiscoveryNexusToken } from './fixture-discovery-classic-nexus';
export type HomeworldPairedNexusStep = { actor: string; action: Action };
export type HomeworldPairedNexusOptions = {
/** Original fresh lobby or original turn-one, unassigned setup. Saved deals are authoritative. */
initial?: Game;
family?: 'ix' | 'choam';
advanced?: boolean;
skills?: boolean;
discovery?: boolean;
tech?: boolean;
strongholds?: boolean;
/** Earn Arrakeen custody through a genuine first-turn three-marker reveal. */
arrakeenClaim?: boolean;
};
export type HomeworldPairedNexusTransition = { before: Game; step: HomeworldPairedNexusStep; after: Game };
export type HomeworldPairedNexusProgramme = {
initial: Game; setup: Game; offered: Game | null; afterSetup: Game; game: Game;
owner: string; native: string; guild: string; fremen: string; trainer: string | null;
firstMarker: HomeworldPairedNexusTransition | null;
entry: HomeworldPairedNexusTransition | null; vote: Game | null;
ride: HomeworldPairedNexusTransition | null; alliance: Game; drawing: Game;
draws: HomeworldPairedNexusTransition[]; actions: HomeworldPairedNexusStep[];
};
export const homeworldPairedNexusClean = nativeHomeworldClean;
export const homeworldPairedNexusPlayer = nativeHomeworldPlayer;
export const homeworldPairedNexusArmy = nativeHomeworldArmy;
/** Conservation covers the original caches as well as ordinary cards, every
* Nexus singleton, trained discs and native/visitor physical counters. */
export function homeworldPairedNexusInventory(game: Game): void {
nativeHomeworldInventory(game);
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
...(game.ixSetupCards ?? []), ...(game.auction?.cards.slice(game.auction.index) ?? []),
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? [])];
assert.deepEqual(cards.map(c => c.id).sort(),
[...treacheryDeck(game.expansions), ...(game.players.some(p => p.faction === 'richese') ? richeseCards() : [])]
.map(c => c.id).sort());
validateNexusCards(game.nexusCards!.cards!, game.players);
const nx = game.nexusCards!.cards!;
assert.deepEqual([...nx.deck, ...nx.discard, ...Object.values(nx.hands).filter(c => c !== null)].sort(), [...NEXUS_FACTIONS].sort());
}
function shuffleRolls(source: readonly string[], desired: readonly string[]): number[] {
assert.deepEqual([...source].sort(), [...desired].sort());
const working = [...source], rolls: number[] = [];
for (let i = working.length - 1; i > 0; i--) {
const j = working.indexOf(desired[i]); assert.ok(j >= 0 && j <= i);
rolls.push(Math.floor((j + 0.5) / (i + 1) * 0x100000000));
[working[i], working[j]] = [working[j], working[i]];
}
return rolls;
}
/** Only word-sized original undealt lotteries are selected. UUID entropy is native. */
function lottery<T>(rolls: readonly number[], operation: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = crypto.getRandomValues.bind(crypto); let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[cursor++] ?? 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');
}
}
/** Original producers retain response priority. Owned Suk, substitution, cards,
* Tech and Face Dance are explicit endpoints; no default replaces a human plan. */
export function nextHomeworldPairedNexusStep(game: Game): HomeworldPairedNexusStep | null {
if (homeworldPairedNexusClean(game) && game.nexusCards?.phase?.stage === 'drawing') {
const phase = game.nexusCards.phase, actor = phase.eligible.find(id => !phase.done.includes(id)); assert.ok(actor);
return { actor, action: { type: 'nexusCardChoice', turn: game.turn,
card: game.nexusCards.cards!.hands[actor] ?? null, choice: 'keep', ownRedraws: 0 } };
}
if (!game.response && !game.phaseOpening && game.decision) {
const d = game.decision;
if (['sukRescue', 'ixSubstitution', 'battleCards', 'faceDance', 'strongholdCopy'].includes(d.kind) ||
d.kind === 'techToken' && !!game.battle) return null;
if (d.kind === 'techToken') return { actor: d.player, action: { type: 'decision', token: d.choices[0] } };
if (d.kind === 'richeseUnbid')
return { actor: d.player, action: { type: 'decision', event: game.richeseBidding!.event, keep: false } };
// Both physical and ordinary Guild arrival windows must allow the declared programme.
if (d.kind === 'guildShipment' || d.kind === 'homeworldShipmentGuild')
return { actor: d.player, action: { type: 'decision', allow: true,
...(d.kind === 'homeworldShipmentGuild' ? { event: d.event } : {}) } };
if (d.kind === 'battleLosses') {
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 (game.discoveries) return game.expansions[0] === 'ix'
? nextDiscoveryPairedNexusE1SkillsStep(game) : nextDiscoveryPairedNexusE2SkillsStep(game);
if (game.leaderSkills) return nextNativeHomeworldModuleStep(game);
return game.expansions[0] === 'ix' ? nextPairedIxNexusModulesStep(game) : nextPairedChoamStep(game);
}
export function stepHomeworldPairedNexus(game: Game, step: HomeworldPairedNexusStep,
actions?: HomeworldPairedNexusStep[]): Game {
actions?.push(structuredClone(step));
return withClassicDiscoveryNexusLottery(() => applyAction(game, step.actor, step.action));
}
export function advanceHomeworldPairedNexus(state: Game, until: (game: Game) => boolean,
actions?: HomeworldPairedNexusStep[]): Game {
let game = structuredClone(state);
for (let n = 0; n < 3000; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextHomeworldPairedNexusStep(game);
assert.ok(step, `Original paired Homeworld programme needs its owned ${game.decision?.kind ?? 'private plan'} at ${game.turn}/${game.phase}`);
game = stepHomeworldPairedNexus(game, step, actions);
}
throw Error('Original paired Homeworld/Nexus programme did not reach its selected boundary.');
}
export function settleHomeworldPairedNexus(game: Game, actions?: HomeworldPairedNexusStep[]): Game {
return advanceHomeworldPairedNexus(game, g => homeworldPairedNexusClean(g) &&
!g.pendingShipment && !g.pendingHomeworldShipment && !g.pendingTreacheryDiscard && !g.pendingChoamWorthless, actions);
}
function transition(game: Game, step: HomeworldPairedNexusStep, actions: HomeworldPairedNexusStep[]): HomeworldPairedNexusTransition {
const before = structuredClone(game), after = stepHomeworldPairedNexus(game, step, actions);
return { before, step: structuredClone(step), after };
}
/** One initializer path for both human fresh lobbies and conserved original
* unassigned setup. This is offline original play, not authenticated HTTP proof. */
export function initializeHomeworldPairedNexus(options: HomeworldPairedNexusOptions = {}) {
const family = options.family ?? (options.initial?.expansions[0] === 'choam' ? 'choam' : 'ix');
const advanced = options.advanced ?? options.initial?.advanced ?? true;
const skills = options.skills ?? (options.initial?.status === 'setup' ? !!options.initial.leaderSkills : true);
const discovery = options.discovery ?? (options.initial ? options.initial.discoveryEnabled === true : false);
const tech = options.tech ?? (options.initial ? !!options.initial.techTokens : true);
const strongholds = options.strongholds ?? (options.initial ? !!options.initial.strongholdCards : advanced);
const roster = family === 'ix' ? ['ixians', 'tleilaxu', 'guild', 'fremen'] as const
: ['richese', 'choam', 'guild', 'fremen'] as const;
let game = options.initial ? structuredClone(options.initial)
: createGame('HOMEWORLDPAIREDNEXUS', newPlayer(roster[0], roster[0], roster[0]), advanced, [family]);
if (!options.initial) for (const faction of roster.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
assert.deepEqual(game.expansions, [family]); assert.equal(game.advanced, advanced);
assert.deepEqual(game.players.map(p => p.faction).sort(), [...roster].sort());
assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 &&
(!game.leaderSkills || game.leaderSkills.assignments.length === 0) && game.players.every(p =>
!p.traitors.length && !p.faceDancers?.length && p.leaders.every(l => !l.dead && !l.usedAt)),
'Continue only an original lobby or its original unassigned first-turn setup.');
const initial = structuredClone(game), actions: HomeworldPairedNexusStep[] = [];
const seat = (faction: string) => game.players.find(p => p.faction === faction)!.id;
const owner = seat(roster[0]), native = seat(roster[1]), guild = seat('guild'), fremen = seat('fremen');
const selected: LeaderSkillId[] = ['suk-graduate', 'prana-bindu-adept', 'swordmaster-of-ginaz', 'warmaster'];
if (game.status === 'lobby') {
for (const [type, enabled, present] of [
['homeworlds', true, !!game.homeworlds], ['techTokens', tech, !!game.techTokens],
['strongholdCards', strongholds, !!game.strongholdCards],
] as const) if (enabled !== present)
game = stepHomeworldPairedNexus(game, { actor: game.host, action: { type, enabled } }, actions);
game.discoveryEnabled = discovery; game.nexusCards ??= { cards: null, phase: null };
for (const p of game.players) if (!p.ready)
game = stepHomeworldPairedNexus(game, { actor: p.id, action: { type: 'ready' } }, actions);
const skillsSource = LEADER_SKILL_CARDS.map(c => c.id), rest = skillsSource.filter(c => !selected.includes(c));
const skillsOrder = selected.flatMap(c => [c, rest.shift()!]).concat(rest);
const deck = treacheryDeck(game.expansions), remaining = [...deck];
const kinds: Card['kind'][] = family === 'ix' ? ['worthless', 'worthless', 'worthless', 'worthless']
: ['worthless', 'special', 'worthless', 'worthless'];
const prefix = kinds.map(kind => {
const i = remaining.findIndex(c => c.kind === kind && (kind !== 'special' || c.effect !== 'karama')); assert.ok(i >= 0);
return remaining.splice(i, 1)[0];
});
const rolls = [...(skills ? shuffleRolls(skillsSource, skillsOrder) : []),
...(discovery ? Array.from({ length: 7 }, () => 0xffffffff) : []),
...Array.from({ length: NEXUS_FACTIONS.length - 1 }, () => 0xffffffff),
...shuffleRolls(deck.map(c => c.id), [...prefix, ...remaining].map(c => c.id))];
game = lottery(rolls, () => skills ? initializeLeaderSkillsGameForAudit(game) : initializePairedNexusGameForAudit(game));
}
assert.equal(!!game.leaderSkills, skills); assert.equal(!!game.discoveries, discovery);
assert.ok(game.homeworlds && game.nexusCards?.cards);
assert.equal(!!game.techTokens, tech); assert.equal(!!game.strongholdCards, strongholds);
const setup = structuredClone(game); let offered: Game | null = null, trainer: string | null = null;
for (let n = 0; game.status === 'setup' && n < 250; n++) {
let step: HomeworldPairedNexusStep;
if (game.setupStage === 'leaderSkills') {
offered ??= structuredClone(game);
const actor = Object.keys(game.leaderSkills!.offers)[0], view = viewGame(game, actor).leaderSkills!;
const wanted = selected[game.players.findIndex(p => p.id === actor)];
const skill = options.initial?.status === 'setup' ? view.offer!.cards.find(c => !view.unavailableSkills?.[c])
: view.offer!.cards.find(c => c === wanted && !view.unavailableSkills?.[c]);
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(skill && leader); if (actor === owner) trainer = leader.id;
step = { actor, action: { type: 'leaderSkill', event: view.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'); assert.ok(card);
step = { actor, action: { type: 'decision', card: card.id } };
} else {
const next = nextHomeworldPairedNexusStep(game); assert.ok(next); step = next;
if (step.action.type === 'traitor' && trainer) {
const choices = nativeHomeworldPlayer(game, step.actor).traitorChoices;
step.action.leader = choices.find(id => id !== trainer) ?? choices[0];
}
}
// A legal original Fremen choice avoids an incidental third allied
// stronghold at END Mentat; it never relocates already placed counters.
if (step.action.type === 'fremenSetup') step.action.placements = {
sietch_tabr: 0, false_wall_south: 10, false_wall_west: 0,
};
if (family === 'ix' && game.setupStage === 'forces' && trainer) {
const held = new Set(game.players.flatMap(p => p.traitors));
const source = traitorDeck(game.players, true).filter(id => !held.has(id)); assert.ok(source.includes(trainer));
actions.push(structuredClone(step));
game = lottery(shuffleRolls(source, [trainer, ...source.filter(id => id !== trainer)]), () => applyAction(game, step.actor, step.action));
} else game = stepHomeworldPairedNexus(game, step, actions);
}
assert.equal(game.status, 'playing'); homeworldPairedNexusInventory(game);
return { initial, setup, offered, afterSetup: structuredClone(game), game, owner, native, guild, fremen, trainer, actions };
}
/** Turn one is played, not repositioned. Turn two's actual worm or Maker opens
* an alliance, then actual unallied closing draws deal the matching singletons. */
export function createHomeworldPairedNexusProgramme(options: HomeworldPairedNexusOptions = {}): HomeworldPairedNexusProgramme {
const s = initializeHomeworldPairedNexus(options), actions = s.actions;
let game = s.game;
const discovery = !!game.discoveries, family = game.expansions[0];
frontNativeHomeworldSpice(game, c => 'territory' in c && (discovery ? c.discovery === 'discovery-hagga-basin' : !c.discovery && c.territory === 'hagga_basin'), 0);
if (game.advanced) frontNativeHomeworldSpice(game, c => 'territory' in c && !c.discovery && c.territory === 'broken_land', 1);
const second = game.advanced ? 2 : 1;
frontNativeHomeworldSpice(game, c => 'worm' in c && (discovery ? !!c.greatMaker : !c.greatMaker && !c.suppressed), second);
frontNativeHomeworldSpice(game, c => 'territory' in c && !c.discovery && c.territory === 'rock_outcroppings', second + 1);
if (game.advanced) frontNativeHomeworldSpice(game, c => 'territory' in c && !c.discovery && c.territory === 'oh_gap', second + 2);
game = advanceHomeworldPairedNexus(game, g => g.phase === 1 && homeworldPairedNexusClean(g), actions);
if (discovery) {
for (let n = 0; !game.discoveries!.tokens.some(t => t.face === 'shrine' && t.status === 'placed') && n < 100; n++) {
const step = nextHomeworldPairedNexusStep(game); assert.ok(step); actions.push(structuredClone(step));
game = withClassicDiscoveryNexusToken(game, 'shrine', () => applyAction(game, step.actor, step.action));
}
assert.ok(game.discoveries!.tokens.some(t => t.face === 'shrine' && t.status === 'placed'));
}
let firstMarker: HomeworldPairedNexusTransition | null = null;
const collector = family === 'ix' ? s.owner : s.guild;
const needed = new Set(discovery ? [collector, ...(family === 'choam' ? [s.owner] : [])] : family === 'choam' ? [s.owner] : []);
while (needed.size) {
game = advanceHomeworldPairedNexus(game, g => g.turn === 1 && g.phase === 5 && homeworldPairedNexusClean(g) && needed.has(g.active!), actions);
const actor = game.active!;
if (family === 'choam' && actor === s.owner) {
const site = options.arrakeenClaim ? 'arrakeen' : 'habbanya_ridge_sietch';
const p = nativeHomeworldPlayer(game, actor), token = p.noField!.tokens.find(t => t.value === (options.arrakeenClaim ? 3 : 0))!;
firstMarker = transition(game, { actor, action: { type: 'ship', territory: site,
sector: territory(site).sectors[0], noField: token.id, event: p.noFieldEvent, allyPayment: 0 } }, actions);
game = settleHomeworldPairedNexus(firstMarker.after, actions); firstMarker.after = structuredClone(game);
game = stepHomeworldPairedNexus(game, { actor, action: { type: 'revealNoField', token: token.id,
event: nativeHomeworldPlayer(game, actor).noFieldEvent } }, actions);
} else {
const placement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'], elite = family === 'ix' ? 1 : 0;
const sources = nativeShipmentSources(viewGame(game, actor), 3, elite); assert.ok(sources);
game = settleHomeworldPairedNexus(stepHomeworldPairedNexus(game, { actor, action: { type: 'ship', territory: placement.territory,
sector: placement.sector, amount: 3, elite, allyPayment: 0, homeworldSources: sources } }, actions), actions);
}
needed.delete(actor);
if (needed.size) game = stepHomeworldPairedNexus(game, { actor, action: { type: 'endMovement' } }, actions);
}
if (discovery) {
game = advanceHomeworldPairedNexus(game, g => g.phase === 7 && homeworldPairedNexusClean(g), actions);
const token = game.discoveries!.tokens.find(t => t.face === 'shrine' && t.status === 'placed')!;
for (const reveal of [false, true]) game = stepHomeworldPairedNexus(game,
{ actor: collector, action: { type: 'discovery', token: token.id, reveal } }, actions);
}
game = advanceHomeworldPairedNexus(game, g => g.turn === 2, actions);
let entry: HomeworldPairedNexusTransition | null = null, vote: Game | null = null, ride: HomeworldPairedNexusTransition | null = null;
if (discovery) {
game = advanceHomeworldPairedNexus(game, g => g.decision?.kind === 'discoveryEntry' && g.decision.player === collector, actions);
const view = viewGame(game, collector), offered = view.discoveryEntry!.sources;
const sources = family === 'ix' ? offered.map(source => ({ ...source, normal: Math.min(source.normal, 1), elite: Math.min(source.elite, 1) })) : offered;
const action = discoveryEntryMoveAction(view, sources); assert.ok(action);
entry = transition(game, { actor: collector, action }, actions); game = entry.after;
game = advanceHomeworldPairedNexus(game, g => g.decision?.kind === 'greatMakerVote', actions); vote = structuredClone(game);
while (game.decision?.kind === 'greatMakerVote') game = stepHomeworldPairedNexus(game,
{ actor: game.decision.player, action: { type: 'decision', event: game.decision.event, yes: true } }, actions);
assert.ok(game.decision?.kind === 'greatMakerRide' && game.decision.player === s.fremen);
const actionRide = greatMakerRideAction(viewGame(game, s.fremen), 'polar_sink', 0, 2, game.advanced ? 1 : 0); assert.ok(actionRide);
ride = transition(game, { actor: s.fremen, action: actionRide }, actions); game = ride.after;
}
game = advanceHomeworldPairedNexus(game, g => !!g.nexus && !g.spiceWindow && !g.spiceResolution && homeworldPairedNexusClean(g), actions);
for (const [actor, target] of [[s.guild, s.fremen], [s.fremen, s.guild]])
game = stepHomeworldPairedNexus(game, { actor, action: { type: 'alliance', target } }, actions);
const alliance = structuredClone(game);
game = advanceHomeworldPairedNexus(game, g => g.nexusCards?.phase?.stage === 'drawing' && homeworldPairedNexusClean(g), actions);
const drawing = structuredClone(game), draws: HomeworldPairedNexusTransition[] = [];
for (const actor of [s.owner, s.native]) {
const cards = game.nexusCards!.cards!, face = nativeHomeworldPlayer(game, actor).faction;
const at = cards.deck.indexOf(face); assert.ok(at >= 0); cards.deck.unshift(cards.deck.splice(at, 1)[0]);
const draw = transition(game, { actor, action: { type: 'nexusCardChoice', turn: game.turn,
card: cards.hands[actor] ?? null, choice: 'draw', ownRedraws: 0 } }, actions);
draws.push(draw); game = draw.after;
}
game = advanceHomeworldPairedNexus(game, g => g.phase === 5 && homeworldPairedNexusClean(g), actions);
homeworldPairedNexusInventory(game);
return { ...s, game, firstMarker, entry, vote, ride, alliance, drawing, draws, actions };
}
/** Legal native sources, actual Guild allowances and custody settlement. */
export function shipHomeworldPairedNexus(game: Game, actor: string, destination: string, amount: number, elite = 0,
actions?: HomeworldPairedNexusStep[]): Game {
const sources = nativeShipmentSources(viewGame(game, actor), amount, elite); assert.ok(sources);
let action: Action;
if (destination.startsWith('homeworld:')) {
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); action = choice.action;
} else action = { type: 'ship', territory: destination, sector: destination === 'shrine' ? 0 : territory(destination).sectors[0],
amount, elite, allyPayment: 0, homeworldSources: sources };
return settleHomeworldPairedNexus(stepHomeworldPairedNexus(game, { actor, action }, actions), actions);
}
export function openHomeworldPairedNexusBattle(game: Game, owner: string, opponent: string, location: string,
hidden = true, actions?: HomeworldPairedNexusStep[]): Game {
game = advanceHomeworldPairedNexus(game, g => g.phase === 6 && homeworldPairedNexusClean(g) && !!g.active, actions);
assert.ok(game.active === owner || game.active === opponent);
game = stepHomeworldPairedNexus(game, { actor: game.active!, action: { type: 'chooseBattle', territory: location,
target: game.active === owner ? opponent : owner } }, actions);
for (let n = 0; n < 200; n++) {
if (homeworldPairedNexusClean(game) && game.battle && !game.battle.preparation && game.battle.preLeader?.closed !== false) return game;
const next = nextHomeworldPairedNexusStep(game); assert.ok(next);
if (next.action.type === 'leaderSkillVisibility') next.action.hide = next.actor === owner && hidden;
game = stepHomeworldPairedNexus(game, next, actions);
}
throw Error('Original Homeworld battle did not reach its sealed-plan boundary.');
}
export function revealHomeworldPairedNexusPlans(game: Game, plans: readonly HomeworldPairedNexusStep[],
actions?: HomeworldPairedNexusStep[]): Game {
for (const plan of plans) {
game = stepHomeworldPairedNexus(game, plan, actions);
while (game.response || game.phaseOpening || game.decision?.kind === 'fullPlanOffer') {
const next = nextHomeworldPairedNexusStep(game); assert.ok(next); game = stepHomeworldPairedNexus(game, next, actions);
}
}
assert.ok(game.battle?.revealed); return game;
}
/** Owned cleanup is explicit, and reward/Face Dance order remains engine-owned. */
export function finishHomeworldPairedNexusBattle(game: Game, actions?: HomeworldPairedNexusStep[]): Game {
for (let n = 0; n < 600; n++) {
if (!game.battle && homeworldPairedNexusClean(game) && !game.pendingTreacheryDiscard) return game;
let next = nextHomeworldPairedNexusStep(game);
if (!next && !game.response && !game.phaseOpening) {
const d = game.decision; assert.ok(d);
if (d.kind === 'battleCards') next = { actor: d.player, action: { type: 'decision', discard: [] } };
else if (d.kind === 'techToken') next = { actor: d.player, action: { type: 'decision', token: d.choices[0] } };
else if (d.kind === 'faceDance') next = { actor: d.player, action: { type: 'decision', reveal: false } };
else throw Error(`Choose the physical ${d.kind} before continuing cleanup.`);
}
assert.ok(next); game = stepHomeworldPairedNexus(game, next, actions);
}
throw Error('Original paired Homeworld aftermath did not finish.');
}
export const homeworldPairedNexusLeader = nativeHomeworldLeader;
+414
View File
@@ -0,0 +1,414 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, viewGame,
type Action, type Game,
} 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 { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { validateDiscoveryState, DISCOVERY_CARD_PLACEMENTS } from '../game/discoveries';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
import { homeworldForceGroups } from '../game/homeworld-custody';
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
import { traitorDeck } from '../game/traitors';
import { validateAmbassadors } from '../game/ecaz-ambassadors';
import {
nativeE3HomeworldPlayer as standaloneHomeworldNexusPlayer,
nativeE3HomeworldPool as standaloneHomeworldNexusPool,
nativeE3HomeworldPolicy as standaloneHomeworldNexusPolicy,
type NativeE3HomeworldOptions,
} from './fixture-homeworld-native-e3-modules';
import {
completeDiscoveryStandaloneNexusE3SkillsSetup, nextDiscoveryStandaloneNexusE3SkillsStep,
type DiscoveryStandaloneNexusE3SkillsBoundary,
} from './fixture-discovery-standalone-nexus-e3-skills';
import { ecazNexusSkillsModulesCunningRequest } from './fixture-ecaz-nexus-skills-modules';
import { withClassicDiscoveryNexusLottery, withClassicDiscoveryNexusToken } from './fixture-discovery-classic-nexus';
export { standaloneHomeworldNexusPlayer, standaloneHomeworldNexusPool, standaloneHomeworldNexusPolicy };
export type StandaloneHomeworldNexusOptions = Omit<NativeE3HomeworldOptions, 'skills' | 'assassination' | 'lethal'>;
export type StandaloneHomeworldNexusStep = { actor: string; action: Action };
export type StandaloneHomeworldNexusTransition = { before: Game; step: StandaloneHomeworldNexusStep; after: Game };
export type StandaloneHomeworldNexusSetup = {
initial: Game; offered: Game; afterSetup: Game; owner: string; opponent: string; trainer: string;
actions: StandaloneHomeworldNexusStep[];
};
export type StandaloneHomeworldNexusClosing = StandaloneHomeworldNexusSetup & {
firstStorm: Game; firstMentat: StandaloneHomeworldNexusTransition; alliance: Game;
draw: StandaloneHomeworldNexusTransition; game: Game;
};
export type StandaloneHomeworldNexusBattle = {
arrival: StandaloneHomeworldNexusTransition; settledArrival: Game; beforeBattle: Game;
game: Game; plans: StandaloneHomeworldNexusStep[]; location: string;
};
export const standaloneHomeworldNexusClean = (g: Game) =>
!g.response && !g.phaseOpening && !g.decision && !g.pendingTreacheryDiscard && !g.truthtrance;
export const reloadStandaloneHomeworldNexus = (g: Game): Game => JSON.parse(JSON.stringify(g)) as Game;
/** The programme owns no alternate profile or producer. This is the same private
* undealt module declaration and host actions used by the original HW fixtures.
* Supplied seat IDs, already-ready seats and genuinely dealt setup offers survive. */
export function createStandaloneHomeworldNexusLobby(options: StandaloneHomeworldNexusOptions = {}): Game {
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 roster = options.roster ?? [faction, 'guild', 'emperor'] as FactionId[];
let g = options.initial ? reloadStandaloneHomeworldNexus(options.initial)
: createGame('STANDALONEHOMENEXUS', newPlayer(roster[0], roster[0], roster[0]), advanced, ['ecaz']);
if (!options.initial) for (const f of roster.slice(1)) joinGame(g, newPlayer(f, f, f));
assert.equal(g.status, 'lobby'); assert.equal(g.advanced, advanced);
assert.deepEqual(g.expansions, ['ecaz']);
assert.equal(g.players.filter(p => p.faction === 'ecaz' || p.faction === 'moritani').length, 1);
assert.equal(new Set(g.players.map(p => p.faction)).size, g.players.length);
assert.ok(g.players.every(p => p.faction === faction || ['guild', 'emperor', 'atreides', 'fremen', 'beneGesserit',
...(!advanced ? ['harkonnen'] : [])].includes(p.faction)));
const tech = options.tech ?? !!g.techTokens, strongholds = options.strongholds ?? !!g.strongholdCards;
assert.ok(g.players.length >= (tech ? 3 : 2) && g.players.length <= 6);
assert.ok(!strongholds || advanced); assert.ok(!g.leaderSkills && !g.ecazTreachery);
if (!g.homeworlds) g = applyAction(g, g.host, { type: 'homeworlds', enabled: true });
if (!!g.techTokens !== tech) g = applyAction(g, g.host, { type: 'techTokens', enabled: tech });
if (!!g.strongholdCards !== strongholds) g = applyAction(g, g.host, { type: 'strongholdCards', enabled: strongholds });
g.discoveryEnabled = options.discovery ?? !!g.discoveryEnabled;
g.nexusCards ??= { cards: null, phase: null };
assert.ok(g.homeworlds?.custody === null && g.nexusCards.cards === null && g.nexusCards.phase === null);
return g;
}
/** Select only an original, not-yet-dealt shuffle. UUID entropy is untouched. */
function permutation(source: readonly string[], desired: readonly string[]): number[] {
const working = [...source], rolls: number[] = [];
assert.deepEqual([...source].sort(), [...desired].sort());
for (let i = working.length - 1; i > 0; i--) {
const j = working.indexOf(desired[i]); assert.ok(j >= 0 && j <= i);
rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1)));
[working[i], working[j]] = [working[j], working[i]];
}
return rolls;
}
function lottery<T>(rolls: readonly number[], operation: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = crypto.getRandomValues.bind(crypto); let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1 && cursor < rolls.length) array[0] = rolls[cursor++];
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 stepStandaloneHomeworldNexus(g: Game, step: StandaloneHomeworldNexusStep,
actions?: StandaloneHomeworldNexusStep[]): Game {
actions?.push(structuredClone(step));
// Actual JSON save continuation is the changed-path programme, not an input-copy assertion.
return withClassicDiscoveryNexusLottery(() => applyAction(reloadStandaloneHomeworldNexus(g), step.actor, step.action));
}
export function nextStandaloneHomeworldNexusStep(g: Game): StandaloneHomeworldNexusStep | null {
if (!g.response && !g.phaseOpening && !g.pendingTreacheryDiscard) {
const d = g.decision;
if (d?.kind === 'guildShipment') return { actor: d.player, action: { type: 'decision', allow: true } };
if (d?.kind === 'homeworldShipmentGuild') return { actor: d.player, action: { type: 'decision', event: d.event, allow: true } };
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 } };
if (d && ['sukRescue', 'moritaniAssassinate', 'techToken', 'ecazBattleLead'].includes(d.kind)) return null;
}
const next = nextDiscoveryStandaloneNexusE3SkillsStep(g);
if (next?.action.type === 'stormDial') next.action.amount = g.turn === 1 ? 0 : 1;
return next;
}
export function advanceStandaloneHomeworldNexus(g: Game, until: (g: Game) => boolean,
actions?: StandaloneHomeworldNexusStep[]): Game {
for (let n = 0; n < 3000; n++) {
if (until(g)) return g;
assert.notEqual(g.status, 'finished');
const next = nextStandaloneHomeworldNexusStep(g);
assert.ok(next, 'Original owner must supply the sealed plan, physical rescue, assassination, coalition lead or winner Tech choice.');
g = stepStandaloneHomeworldNexus(g, next, actions);
}
throw Error('Original standalone HW/Nexus chronology did not reach its owned boundary.');
}
export function assertStandaloneHomeworldNexusCustody(g: Game): void {
homeworldGameIntegrity(g); validateLeaderSkills(g.leaderSkills!, g.players);
validateNexusCards(g.nexusCards!.cards!, g.players);
if (g.discoveries) validateDiscoveryState(g.discoveries);
const skills = g.leaderSkills!, nx = g.nexusCards!.cards!;
assert.deepEqual([...skills.deck, ...Object.values(skills.offers).flatMap(o => o.cards), ...skills.assignments.map(a => a.skill)].sort(),
LEADER_SKILL_CARDS.map(c => c.id).sort());
assert.deepEqual([...nx.deck, ...nx.discard, ...Object.values(nx.hands).filter((c): c is FactionId => c !== null)].sort(), [...NEXUS_FACTIONS].sort());
const auction = g.auction?.cards.slice(g.auction.index + (g.currentAuctionSale ? 1 : 0)) ?? [];
assert.deepEqual([...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand), ...auction].map(c => c.id).sort(),
treacheryDeck(['ecaz']).map(c => c.id).sort());
if (g.traitorReserve) {
const retired = g.moritaniAssassinate?.opportunities.filter(o => o.stage === 'replaced').map(o => o.card!) ?? [];
assert.deepEqual([...g.traitorReserve, ...g.players.flatMap(p => [...p.traitors, ...p.traitorChoices]), ...retired,
...(g.ecazLoyalty?.card ? [g.ecazLoyalty.card] : [])].sort(), traitorDeck(g.players).sort());
}
// Physical native reserves and the separate Duke are produced only AFTER
// original Traitor selection; unassigned setup has no home custody to count.
if (!g.homeworlds?.custody) return;
if (g.ecazAmbassadors) validateAmbassadors(g.ecazAmbassadors);
const homes = homeworldForceGroups(homeworldContext(g), g.homeworlds!.custody!);
for (const p of g.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((n, f) => n + f.normal + f.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((n, f) => n + f.elite, 0), p.faction === 'fremen' ? 3 : 5);
assert.deepEqual(p.leaders.map(l => l.id).sort(), leaders(p.faction).map(l => l.id).sort());
}
assert.equal(g.dukeVidal!.leader.id, DUKE_VIDAL_ID);
assert.ok(!skills.assignments.some(a => a.leader === DUKE_VIDAL_ID));
}
export function createStandaloneHomeworldNexusSetup(options: StandaloneHomeworldNexusOptions = {}): StandaloneHomeworldNexusSetup {
const actions: StandaloneHomeworldNexusStep[] = [];
const suppliedSetup = options.initial?.status === 'setup';
let g = suppliedSetup ? reloadStandaloneHomeworldNexus(options.initial!) : createStandaloneHomeworldNexusLobby(options);
const initial = reloadStandaloneHomeworldNexus(g);
const owner = g.players.find(p => p.faction === 'ecaz' || p.faction === 'moritani')!.id;
const opponent = g.players.find(p => p.faction === 'guild')?.id ?? g.players.find(p => p.id !== owner)!.id;
if (!suppliedSetup) {
for (const p of g.players) if (!p.ready) g = stepStandaloneHomeworldNexus(g, { actor: p.id, action: { type: 'ready' } }, actions);
if (options.initial) g = initializeLeaderSkillsGameForAudit(g);
else {
const requested = options.skill ?? 'warmaster';
const opposing: LeaderSkillId = requested === 'suk-graduate' ? 'swordmaster-of-ginaz' : 'suk-graduate';
const passive: LeaderSkillId[] = ['master-of-assassins', 'killer-medic', 'prana-bindu-adept', 'swordmaster-of-ginaz', 'planetologist']
.filter(s => s !== requested && s !== opposing) as LeaderSkillId[];
const selected: LeaderSkillId[] = g.players.map(p => p.id === owner ? requested
: p.id === opponent ? opposing : passive.shift()!);
const source = LEADER_SKILL_CARDS.map(c => c.id), rest = source.filter(c => !selected.includes(c));
assert.equal(new Set(selected).size, selected.length, 'A generated programme needs distinct requested physical skills.');
const desired = selected.flatMap(c => [c, rest.shift()!]).concat(rest);
const cards = treacheryDeck(['ecaz']), remaining = [...cards];
const front = g.players.flatMap(p => {
const kinds = [p.id === opponent && g.players.some(s => s.faction === 'moritani') ? 'projectile' : 'worthless',
...(p.faction === 'harkonnen' ? ['shield'] : [])];
return kinds.map(kind => {
const selectedKind = kind === 'worthless' && !remaining.some(c => c.kind === 'worthless') ? 'shield' : kind;
const index = remaining.findIndex(c => c.kind === selectedKind); assert.ok(index >= 0);
return remaining.splice(index, 1)[0].id;
});
});
const native = standaloneHomeworldNexusPlayer(g, owner).faction;
const rolls = [...permutation(source, desired), ...(g.discoveryEnabled ? Array<number>(7).fill(0xffffffff) : []),
...permutation(NEXUS_FACTIONS, [native, ...NEXUS_FACTIONS.filter(c => c !== native)]),
...permutation(cards.map(c => c.id), [...front, ...remaining.map(c => c.id)])];
g = lottery(rolls, () => initializeLeaderSkillsGameForAudit(g));
}
}
assert.ok(g.status === 'setup' && g.turn === 1 && g.phase === 0 && ['prediction', 'leaderSkills'].includes(g.setupStage!) &&
g.leaderSkills?.assignments.length === 0 && g.homeworlds && g.nexusCards?.cards,
'Continue only ORIGINAL unassigned skill-first setup; never convert a played save.');
const offered = reloadStandaloneHomeworldNexus(g);
g = completeDiscoveryStandaloneNexusE3SkillsSetup(g, { initial: options.initial,
skill: options.skill ?? (!options.initial ? 'warmaster' : undefined) }, actions);
assertStandaloneHomeworldNexusCustody(g);
const trainer = g.leaderSkills!.assignments.find(a => a.owner === owner)!.leader;
return { initial, offered, afterSetup: g, owner, opponent, trainer, actions };
}
/** Only ORIGINAL unplayed Spice Cards are reordered, never cards in either played pile. */
export function orderStandaloneHomeworldNexusSpice(g: Game, worm = false): void {
const kinds = [...(worm ? ['worm'] : []), ...Array<string>(g.advanced ? 2 : 1).fill('land')];
for (const [position, kind] of kinds.entries()) {
const index = g.spiceDeck.findIndex((c, i) => i >= position && (kind === 'worm'
? 'worm' in c && !c.greatMaker : 'territory' in c && !c.discovery));
assert.ok(index >= position); g.spiceDeck.splice(position, 0, g.spiceDeck.splice(index, 1)[0]);
}
}
export function createStandaloneHomeworldNexusClosing(options: StandaloneHomeworldNexusOptions = {}): StandaloneHomeworldNexusClosing {
const f = createStandaloneHomeworldNexusSetup(options);
assert.ok(f.afterSetup.players.length >= 3, 'A qualifying native closing draw needs two other real alliance seats.');
let g = reloadStandaloneHomeworldNexus(f.afterSetup);
orderStandaloneHomeworldNexusSpice(g);
g = advanceStandaloneHomeworldNexus(g, s => s.phase === 1 && standaloneHomeworldNexusClean(s), f.actions);
const firstStorm = reloadStandaloneHomeworldNexus(g);
g = advanceStandaloneHomeworldNexus(g, s => s.phase === 8 && standaloneHomeworldNexusClean(s), f.actions);
let firstMentat: StandaloneHomeworldNexusTransition | undefined;
while (g.turn === 1) {
const before = reloadStandaloneHomeworldNexus(g), step = nextStandaloneHomeworldNexusStep(g); assert.ok(step);
g = stepStandaloneHomeworldNexus(g, step, f.actions);
if (g.turn === 2) firstMentat = { before, step, after: reloadStandaloneHomeworldNexus(g) };
}
assert.ok(firstMentat);
g = advanceStandaloneHomeworldNexus(g, s => s.phase === 1 && standaloneHomeworldNexusClean(s), f.actions);
orderStandaloneHomeworldNexusSpice(g, true);
g = advanceStandaloneHomeworldNexus(g, s => s.nexus && !s.spiceWindow && !s.spiceResolution && standaloneHomeworldNexusClean(s), f.actions);
const others = g.players.filter(p => p.id !== f.owner);
for (const [actor, target] of [[others[0].id, others[1].id], [others[1].id, others[0].id]])
g = stepStandaloneHomeworldNexus(g, { actor, action: { type: 'alliance', target } }, f.actions);
const alliance = reloadStandaloneHomeworldNexus(g);
g = advanceStandaloneHomeworldNexus(g, s => s.nexusCards?.phase?.stage === 'drawing' && standaloneHomeworldNexusClean(s), f.actions);
assert.ok(g.nexusCards!.phase!.eligible.includes(f.owner));
const before = reloadStandaloneHomeworldNexus(g), step: StandaloneHomeworldNexusStep = { actor: f.owner,
action: { type: 'nexusCardChoice', turn: g.turn, card: g.nexusCards!.cards!.hands[f.owner], choice: 'draw', ownRedraws: 0 } };
g = stepStandaloneHomeworldNexus(g, step, f.actions);
return { ...f, firstStorm, firstMentat, alliance, draw: { before, step, after: reloadStandaloneHomeworldNexus(g) }, game: g };
}
/** The ORIGINAL undealt Treachery supply selects these two auction faces; only
* real one-spice winning bids and native payment put them into Moritani's hand. */
export function buyStandaloneHomeworldMoritaniArmament(f: StandaloneHomeworldNexusClosing, state: Game) {
let g = advanceStandaloneHomeworldNexus(state, s => s.phase === 2 && standaloneHomeworldNexusClean(s), f.actions);
const before = reloadStandaloneHomeworldNexus(g), cards: string[] = [], front: Game['deck'] = [];
for (const kind of ['projectile', 'shield']) {
const held = standaloneHomeworldNexusPlayer(g, f.owner).hand.find(c => c.kind === kind);
if (held) { cards.push(held.id); continue; }
const index = g.deck.findIndex(c => c.kind === kind); assert.ok(index >= 0);
const card = g.deck.splice(index, 1)[0]; cards.push(card.id); front.push(card);
}
g.deck.unshift(...front);
for (let n = 0; n < 320; n++) {
if (cards.every(id => standaloneHomeworldNexusPlayer(g, f.owner).hand.some(c => c.id === id)))
return { before, game: g, weapon: cards[0], defense: cards[1], bought: front.map(c => c.id) };
const owned = nextStandaloneHomeworldNexusStep(g); assert.ok(owned);
if (g.phase === 3 && standaloneHomeworldNexusClean(g) && g.auction && !g.auction.bid &&
g.auction.active === f.owner && cards.includes(g.auction.cards[g.auction.index]?.id))
owned.action = { type: 'bid', amount: 1 };
g = stepStandaloneHomeworldNexus(g, owned, f.actions);
}
throw Error('The original paid auction did not deliver Moritani armament.');
}
export function invadeStandaloneHomeworldNexus(g: Game, actor: string, destination: string, amount: number,
actions?: StandaloneHomeworldNexusStep[]): StandaloneHomeworldNexusTransition & { settled: Game } {
g = advanceStandaloneHomeworldNexus(g, s => s.phase === 5 && s.active === actor && standaloneHomeworldNexusClean(s), actions);
const before = reloadStandaloneHomeworldNexus(g), view = viewGame(g, actor);
const sources = nativeShipmentSources(view, amount, 0); assert.ok(sources);
const selected = Object.fromEntries(Object.entries(sources).filter(([, f]) => f.normal + f.elite > 0));
const choice = homeworldShipmentChoice(view, destination, selected); assert.ok(choice.action, choice.blocked ?? 'Original invasion unavailable.');
const step = { actor, action: choice.action };
g = stepStandaloneHomeworldNexus(g, step, actions);
const after = reloadStandaloneHomeworldNexus(g);
g = advanceStandaloneHomeworldNexus(g, s => !s.pendingHomeworldShipment && !s.pendingShipment && standaloneHomeworldNexusClean(s), actions);
return { before, step, after, settled: g };
}
export function openStandaloneHomeworldNexusBattle(g: Game, location: string, first: string, second: string,
actions?: StandaloneHomeworldNexusStep[]): Game {
g = advanceStandaloneHomeworldNexus(g, s => s.phase === 6 && standaloneHomeworldNexusClean(s) && !!s.active, actions);
assert.ok(g.active === first || g.active === second);
g = stepStandaloneHomeworldNexus(g, { actor: g.active!, action: { type: 'chooseBattle', territory: location,
target: g.active === first ? second : first } }, actions);
return advanceStandaloneHomeworldNexus(g, s => !!s.battle && standaloneHomeworldNexusClean(s) &&
!s.battle.preparation && s.battle.preLeader?.closed !== false, actions);
}
export function revealStandaloneHomeworldNexusPlans(g: Game, plans: StandaloneHomeworldNexusStep[],
actions?: StandaloneHomeworldNexusStep[]): Game {
for (const step of plans) {
g = stepStandaloneHomeworldNexus(g, step, actions);
if (!g.battle?.revealed) g = advanceStandaloneHomeworldNexus(g, s => !!s.battle && standaloneHomeworldNexusClean(s) &&
!s.battle.preparation && s.battle.preLeader?.closed !== false, actions);
}
assert.ok(g.battle?.revealed); return g;
}
export function settleStandaloneHomeworldNexus(g: Game, actions?: StandaloneHomeworldNexusStep[]): DiscoveryStandaloneNexusE3SkillsBoundary {
const choices: Partial<Record<NonNullable<Game['decision']>['kind'], DiscoveryStandaloneNexusE3SkillsBoundary['kind']>> = {
battleLosses: 'losses', sukRescue: 'rescue', moritaniAssassinate: 'assassination', battleCards: 'cards', techToken: 'tech',
};
g = advanceStandaloneHomeworldNexus(g, s => !s.response && !s.phaseOpening &&
(!!(s.decision && choices[s.decision.kind]) || !s.battle && standaloneHomeworldNexusClean(s)), actions);
return { game: g, kind: g.decision && choices[g.decision.kind] || 'settled' };
}
export function createStandaloneHomeworldEcazCunning(options: StandaloneHomeworldNexusOptions = {}) {
const f = createStandaloneHomeworldNexusClosing({ ...options, faction: 'ecaz', advanced: true,
roster: ['ecaz', 'guild', 'beneGesserit'], tech: options.tech ?? true, strongholds: options.strongholds ?? true });
const opponent = f.afterSetup.players.find(p => p.faction === 'beneGesserit')!.id;
const arrival = invadeStandaloneHomeworldNexus(f.game, f.owner, 'homeworld:beneGesserit', 1, f.actions);
let g = advanceStandaloneHomeworldNexus(arrival.settled, s => s.phase === 6 && standaloneHomeworldNexusClean(s) && !s.battle, f.actions);
const before = reloadStandaloneHomeworldNexus(g), step = ecazNexusSkillsModulesCunningRequest(g, f.owner);
g = stepStandaloneHomeworldNexus(g, step, f.actions);
const cunning = { before, step, after: reloadStandaloneHomeworldNexus(g) };
g = openStandaloneHomeworldNexusBattle(g, 'homeworld:beneGesserit', f.owner, opponent, f.actions);
const trained = new Set(g.leaderSkills!.assignments.map(a => a.leader));
const own = standaloneHomeworldNexusPlayer(g, f.owner).leaders.filter(l => !trained.has(l.id)).sort((a, b) => a.strength - b.strength)[0];
const enemy = standaloneHomeworldNexusPlayer(g, opponent).leaders.filter(l => !trained.has(l.id)).sort((a, b) => b.strength - a.strength)[0];
assert.ok(own && enemy);
const plans: StandaloneHomeworldNexusStep[] = [
{ actor: f.owner, action: { type: 'battlePlan', leader: own.id, dial: 0, support: 0 } },
{ actor: opponent, action: { type: 'battlePlan', leader: enemy.id, dial: 3, support: 3 } },
];
return { ...f, opponent, arrival, cunning, game: g, plans, location: 'homeworld:beneGesserit' };
}
/** First original loss: the unused living Guild trainer remains eligible for
* native private assassination only AFTER its real same-home normal Suk rescue. */
export function openStandaloneHomeworldMoritaniDeath(f: StandaloneHomeworldNexusClosing, state: Game, leader: string,
first = false): StandaloneHomeworldNexusBattle {
const arrival = invadeStandaloneHomeworldNexus(state, f.owner, 'homeworld:guild', 1, f.actions);
const beforeBattle = advanceStandaloneHomeworldNexus(arrival.settled,
s => s.phase === 6 && standaloneHomeworldNexusClean(s), f.actions);
const g = openStandaloneHomeworldNexusBattle(beforeBattle, 'homeworld:guild', f.owner, f.opponent, f.actions);
const trained = new Set(g.leaderSkills!.assignments.map(a => a.leader));
const winner = standaloneHomeworldNexusPlayer(g, f.opponent).leaders.filter(l => !l.dead && !trained.has(l.id))
.sort((a, b) => b.strength - a.strength)[0];
const weapon = standaloneHomeworldNexusPlayer(g, f.opponent).hand.find(c => c.kind === 'projectile');
assert.ok(winner && weapon, 'The genuine Guild starting deal must provide its living winner and retained projectile.');
const worthless = first ? standaloneHomeworldNexusPlayer(g, f.owner).hand.find(c => c.kind === 'worthless')?.id : undefined;
return { arrival, settledArrival: arrival.settled, beforeBattle, game: g, location: 'homeworld:guild', plans: [
{ actor: f.owner, action: { type: 'battlePlan', leader, dial: 0, support: 0, weapon: worthless } },
{ actor: f.opponent, action: { type: 'battlePlan', leader: winner.id, dial: first ? 1 : 0,
support: g.advanced && first ? 1 : 0, weapon: weapon.id } },
] };
}
/** Four later Guild discs die by actual paid invasions, against an actually
* auctioned native projectile and Shield. The assassinated fifth disc is never
* restaged; the Guild must establish all-five deaths to revive its OWN trainer. */
export function openStandaloneHomeworldGuildDeath(f: StandaloneHomeworldNexusClosing, state: Game, leader: string,
weapon: string, defense: string): StandaloneHomeworldNexusBattle {
const arrival = invadeStandaloneHomeworldNexus(state, f.opponent, 'homeworld:moritani', 1, f.actions);
const beforeBattle = advanceStandaloneHomeworldNexus(arrival.settled,
s => s.phase === 6 && standaloneHomeworldNexusClean(s), f.actions);
const g = openStandaloneHomeworldNexusBattle(beforeBattle, 'homeworld:moritani', f.owner, f.opponent, f.actions);
const own = standaloneHomeworldNexusPlayer(g, f.owner);
assert.ok(own.hand.some(c => c.id === weapon) && own.hand.some(c => c.id === defense));
const winner = own.leaders.filter(l => !l.dead &&
!g.leaderSkills!.assignments.some(a => a.owner === f.owner && a.leader === l.id))
.sort((a, b) => b.strength - a.strength)[0]; assert.ok(winner);
const enemyWeapon = standaloneHomeworldNexusPlayer(g, f.opponent).hand.find(c => c.kind === 'projectile');
return { arrival, settledArrival: arrival.settled, beforeBattle, game: g, location: 'homeworld:moritani', plans: [
{ actor: f.owner, action: { type: 'battlePlan', leader: winner.id, dial: 1, support: g.advanced ? 1 : 0, weapon, defense } },
{ actor: f.opponent, action: { type: 'battlePlan', leader, dial: 0, support: 0, weapon: enemyWeapon?.id } },
] };
}
/** Optional nested entry uses the original supply lottery, paid parent shipment,
* real inspection/reveal and next-turn free move; native reserves never move at entry. */
export function createStandaloneHomeworldNexusDiscovery(options: StandaloneHomeworldNexusOptions = {}) {
const f = createStandaloneHomeworldNexusSetup({ ...options, discovery: true });
let g = reloadStandaloneHomeworldNexus(f.afterSetup);
const index = g.spiceDeck.findIndex(c => 'territory' in c && c.discovery === 'discovery-hagga-basin'); assert.ok(index >= 0);
g.spiceDeck.unshift(g.spiceDeck.splice(index, 1)[0]);
const position = g.spiceDeck.findIndex((c, i) => i > 0 && 'territory' in c && !c.discovery && c.territory !== 'hagga_basin');
assert.ok(position > 0); g.spiceDeck.splice(1, 0, g.spiceDeck.splice(position, 1)[0]);
g = advanceStandaloneHomeworldNexus(g, s => s.phase === 1 && standaloneHomeworldNexusClean(s), f.actions);
for (let n = 0; !g.discoveries!.tokens.some(t => t.face === 'cistern' && t.status === 'placed') && n < 160; n++) {
const step = nextStandaloneHomeworldNexusStep(g); assert.ok(step); f.actions.push(structuredClone(step));
g = withClassicDiscoveryNexusToken(g, 'cistern', () => applyAction(reloadStandaloneHomeworldNexus(g), step.actor, step.action));
}
g = advanceStandaloneHomeworldNexus(g, s => s.phase === 5 && s.active === f.owner && standaloneHomeworldNexusClean(s), f.actions);
const beforeShipment = reloadStandaloneHomeworldNexus(g), destination = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
const sources = nativeShipmentSources(viewGame(g, f.owner), 2, 0); assert.ok(sources);
const shipment: StandaloneHomeworldNexusStep = { actor: f.owner, action: { type: 'ship', territory: destination.territory,
sector: destination.sector, amount: 2, elite: 0, allyPayment: 0, homeworldSources: sources } };
g = stepStandaloneHomeworldNexus(g, shipment, f.actions);
g = advanceStandaloneHomeworldNexus(g, s => !s.pendingShipment && standaloneHomeworldNexusClean(s), f.actions);
const afterShipment = reloadStandaloneHomeworldNexus(g);
g = advanceStandaloneHomeworldNexus(g, s => s.phase === 7 && standaloneHomeworldNexusClean(s), f.actions);
const token = g.discoveries!.tokens.find(t => t.face === 'cistern' && t.status === 'placed')!.id;
if (viewGame(g, f.owner).discoveries!.canInspect.includes(token))
g = stepStandaloneHomeworldNexus(g, { actor: f.owner, action: { type: 'discovery', token, reveal: false } }, f.actions);
g = stepStandaloneHomeworldNexus(g, { actor: f.owner, action: { type: 'discovery', token, reveal: true } }, f.actions);
const revealed = reloadStandaloneHomeworldNexus(g);
g = advanceStandaloneHomeworldNexus(g, s => s.turn === 2 && !s.response && !s.phaseOpening &&
s.decision?.kind === 'discoveryEntry' && s.decision.player === f.owner, f.actions);
const before = reloadStandaloneHomeworldNexus(g), view = viewGame(g, f.owner);
const action = discoveryEntryMoveAction(view, view.discoveryEntry!.sources); assert.ok(action);
const step = { actor: f.owner, action }; g = stepStandaloneHomeworldNexus(g, step, f.actions);
return { ...f, beforeShipment, shipment, afterShipment, revealed, token, entry: { before, step, after: g }, game: g };
}
@@ -0,0 +1,451 @@
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 { territory } from '../game/board';
import { ownedTech } from '../game/tech-tokens';
import { quotePhaseResources } from '../game/phase-resource-quote';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { discoveryPairedNexusE2SkillsCunningAction } from './fixture-discovery-paired-nexus-e2-skills';
import { pairedSkillsChoamCunningRequest, quotePairedSkillsBattle } from './fixture-paired-choam-nexus-skills';
import {
advanceHomeworldPairedNexus as advance, createHomeworldPairedNexusProgramme as programme,
finishHomeworldPairedNexusBattle as finishBattle, homeworldPairedNexusArmy as army,
homeworldPairedNexusClean as clean, homeworldPairedNexusInventory as inventory,
homeworldPairedNexusLeader as leader, homeworldPairedNexusPlayer as player,
openHomeworldPairedNexusBattle as openBattle, revealHomeworldPairedNexusPlans as revealPlans,
settleHomeworldPairedNexus as settle, shipHomeworldPairedNexus as ship,
stepHomeworldPairedNexus as step, type HomeworldPairedNexusStep,
} from './fixture-homeworld-paired-nexus';
/** Every consumer begins with the original initializer, actual owned actions,
* conserved unused lotteries and unplayed Spice order. No held card, board
* position, wallet, alliance, outcome or authenticated receipt is assigned. */
function reject(game: Game, actor: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before, 'Rejected consumers leave physical stock and payments unchanged.');
}
function policies(game: Game, actor: string, select: (action: Action) => boolean,
outcome: (after: Game, action: Action) => void): 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(select); assert.ok(action, `${difficulty} selects the actual owned window`);
const after = applyAction(structuredClone(game), actor, action);
outcome(after, action); inventory(after);
}
}
function consumeSuboids(game: Game, owner: string, actions?: HomeworldPairedNexusStep[]): Game {
const offer = viewGame(game, owner).nexusSuboids?.offer; assert.ok(offer && !offer.blocked);
const action: Action = { type: 'nexusSuboids', event: offer.event };
reject(game, owner, { ...action, event: 'obsolete-suboid-battle' });
policies(game, owner, a => a.type === 'nexusSuboids', after => {
const resolved = settle(after);
assert.equal(resolved.nexusCards!.cards!.hands[owner], null);
assert.equal(resolved.nexusCards!.cards!.discard.filter(c => c === 'ixians').length, 1);
assert.deepEqual(player(resolved, owner).forces, player(game, owner).forces);
assert.equal(viewGame(resolved, owner).battle!.ownForces!.normalFreeSupport, true);
});
const played = settle(step(game, { actor: owner, action }, actions), actions);
assert.equal(played.nexusCards!.cards!.hands[owner], null);
assert.equal(played.nexusCards!.cards!.discard.filter(c => c === 'ixians').length, 1);
assert.equal(viewGame(played, owner).battle!.ownForces!.normalFreeSupport, true);
reject(played, owner, action); inventory(played);
return played;
}
function atMovement(game: Game, actor: string, turn = 2): Game {
return advance(game, g => g.turn === turn && g.phase === 5 && g.active === actor && clean(g));
}
void test('Basic paired E1 without Skills or Discovery genuinely draws matching Cunning, invades native Ix and preserves foreign-Homeworld Face Dance suppression', () => {
const f = programme({ advanced: false, skills: false, discovery: false, tech: true, strongholds: false });
assert.equal(player(f.alliance, f.guild).ally, f.fremen);
assert.equal(player(f.alliance, f.fremen).ally, f.guild);
policies(f.draws[0].before, f.owner, a => a.type === 'nexusCardChoice', after => {
assert.equal(after.nexusCards!.cards!.hands[f.owner], 'ixians');
assert.equal(player(after, f.owner).spice, player(f.draws[0].before, f.owner).spice);
assert.deepEqual(after.homeworlds!.custody, f.draws[0].before.homeworlds!.custody);
});
for (const draw of f.draws) {
assert.equal(draw.before.nexusCards!.cards!.hands[draw.step.actor], null);
assert.equal(draw.after.nexusCards!.cards!.hands[draw.step.actor], player(draw.after, draw.step.actor).faction);
assert.equal(player(draw.after, draw.step.actor).spice, player(draw.before, draw.step.actor).spice);
}
let game = ship(atMovement(f.game, f.guild), f.guild, 'homeworld:ixians', 3);
game = consumeSuboids(openBattle(game, f.owner, f.guild, 'homeworld:ixians', false), f.owner);
const own = leader(game, f.owner, true), before = structuredClone(game), lostTech = ownedTech(game.techTokens, f.guild);
game = revealPlans(game, [{ actor: f.owner, action: { type: 'battlePlan', leader: own, dial: 0, support: 0 } },
{ actor: f.guild, action: { type: 'battlePlan', leader: leader(game, f.guild), dial: 0, support: 0 } }]);
game = finishBattle(game);
assert.equal(game.lastBattleContext!.winner, f.owner);
assert.deepEqual(army(game, f.owner, 'homeworld:ixians'), army(before, f.owner, 'homeworld:ixians'));
assert.deepEqual(army(game, f.guild, 'homeworld:ixians'), { normal: 0, elite: 0 });
assert.equal(player(game, f.guild).tanks, player(before, f.guild).tanks + 3);
for (const token of lostTech) assert.equal(game.techTokens![token].owner, f.owner);
assert.equal(player(game, f.owner).leaders.find(l => l.id === own)!.dead, false);
assert.ok(player(game, f.native).faceDancers!.every(c => !c.revealed));
reject(game, f.native, { type: 'decision', reveal: true, sources: { reserves: 1 }, sector: 0 });
inventory(game);
});
for (const visitor of [false, true]) void test(`Advanced matching Suboid Cunning and ${visitor ? 'visitor' : 'native'} typed Suk leave only real residual Cyborg Tanks available for equal Suboid exchange`, () => {
const f = programme({ advanced: true, skills: true, discovery: false, tech: true, strongholds: true });
assert.ok(f.trainer);
const destination = visitor ? 'homeworld:guild' : 'homeworld:ixians';
const sender = visitor ? f.owner : f.guild;
let game = ship(atMovement(f.game, sender), sender, destination, visitor ? 4 : 3, visitor ? 3 : 0);
game = consumeSuboids(openBattle(game, f.owner, f.guild, destination), f.owner);
const before = structuredClone(game), enemyTech = ownedTech(game.techTokens, f.guild);
game = revealPlans(game, [{ actor: f.owner, action: { type: 'battlePlan', leader: f.trainer, dial: 3, support: 0 } },
{ actor: f.guild, action: { type: 'battlePlan', leader: leader(game, f.guild), dial: 0, support: 0 } }]);
game = advance(game, g => g.decision?.kind === 'sukRescue');
const d = game.decision; assert.ok(d?.kind === 'sukRescue');
assert.deepEqual(army(game, f.owner, destination), army(before, f.owner, destination), 'Casualty commitment has not yet killed or moved physical counters.');
policies(game, f.owner, a => a.type === 'decision' && a.choice !== undefined, (after, action) => {
const rescue = d.options[Number(action.choice)], saved = rescue.normal + rescue.elite;
assert.equal(player(after, f.owner).tanks, player(game, f.owner).tanks + 3 - saved);
assert.equal(player(after, f.owner).elites!.tanks, player(game, f.owner).elites!.tanks + 3 - rescue.elite);
assert.deepEqual(army(after, f.owner, 'homeworld:ixians'), visitor
? { normal: army(game, f.owner, 'homeworld:ixians').normal,
elite: army(game, f.owner, 'homeworld:ixians').elite + rescue.elite - (rescue.kept?.kind === 'elite' ? 1 : 0) }
: { normal: army(game, f.owner, destination).normal, elite: army(game, f.owner, destination).elite - 3 + rescue.elite });
});
const choice = d.options.findIndex(o => o.normal === 0 && o.elite === 1 && o.kept?.kind === 'elite'); assert.ok(choice >= 0);
const rescueAction: Action = { type: 'decision', event: d.event, choice };
reject(game, f.guild, rescueAction);
reject(game, f.owner, { ...rescueAction, event: 'obsolete-suk' });
reject(game, f.owner, { ...rescueAction, destinations: { 'homeworld:ixians': { normal: 0, elite: 1 } } });
game = step(JSON.parse(JSON.stringify(game)) as Game, { actor: f.owner, action: rescueAction });
game = advance(game, g => g.decision?.kind === 'ixSubstitution');
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.deepEqual(physical.destinations, {});
assert.deepEqual(game.pendingIxSubstitution!.losses, { [destination]: 2 });
const residual = structuredClone(game), pool = army(game, f.owner, destination);
const exchange: Action = { type: 'decision', sources: { [destination]: 1 }, recover: { [destination]: 1 } };
reject(game, f.owner, { ...exchange, recover: { [destination]: 3 } });
reject(game, f.owner, { ...exchange, sources: { reserves: 1 } });
reject(game, f.owner, { ...exchange, recover: { 'homeworld:tleilaxu': 1 } });
policies(game, f.owner, a => a.type === 'decision' && (a.decline === true || a.sources !== undefined), (after, action) => {
const count = action.decline ? 0 : Object.values((action.recover ?? {}) as Record<string, number>).reduce((sum, n) => sum + n, 0);
assert.equal(player(after, f.owner).elites!.tanks, player(residual, f.owner).elites!.tanks - count);
assert.deepEqual(army(after, f.owner, destination), { normal: pool.normal - count, elite: pool.elite + count });
assert.equal(player(after, f.owner).spice, player(residual, f.owner).spice);
});
game = step(game, { actor: f.owner, action: exchange });
game = advance(game, g => !g.pendingIxSubstitution);
assert.deepEqual(army(game, f.owner, destination), { normal: pool.normal - 1, elite: pool.elite + 1 });
assert.equal(player(game, f.owner).elites!.tanks, player(residual, f.owner).elites!.tanks - 1);
assert.equal(player(game, f.owner).tanks, player(residual, f.owner).tanks);
assert.equal(player(game, f.owner).spice, player(residual, f.owner).spice);
if (visitor) assert.deepEqual(army(game, f.owner, 'homeworld:ixians'), army(residual, f.owner, 'homeworld:ixians'));
reject(game, f.owner, exchange);
game = finishBattle(game);
assert.equal(game.lastBattleContext!.winner, f.owner);
for (const token of enemyTech) assert.equal(game.techTokens![token].owner, f.owner);
assert.ok(player(game, f.native).faceDancers!.every(c => !c.revealed), 'Foreign Homeworld never opens the forbidden native replacement.');
inventory(game);
});
void test('actual Maker entry and matching nested Cunning preserve typed sources; cleanup precedes winner Tech and legal matching Face Dance without a borrowed-native grant', () => {
const f = programme({ advanced: true, discovery: true, skills: true, tech: true, strongholds: true });
assert.ok(f.entry && f.ride && f.vote && f.trainer);
const entered = f.entry.after;
assert.equal(player(entered, f.owner).forces['shrine:0'], 2);
assert.equal(player(entered, f.owner).elites!.forces['shrine:0'], 1);
assert.deepEqual(entered.homeworlds!.custody, f.entry.before.homeworlds!.custody);
assert.equal(player(entered, f.owner).spice, player(f.entry.before, f.owner).spice);
policies(f.entry.before, f.owner, a => a.type === 'decision' && a.accept === false, after => {
assert.equal(player(after, f.owner).forces['shrine:0'] ?? 0, 0);
assert.deepEqual(player(after, f.owner).forces, player(f.entry!.before, f.owner).forces);
assert.deepEqual(after.homeworlds!.custody, f.entry!.before.homeworlds!.custody);
});
const rideBefore = player(f.ride.before, f.fremen), rideAfter = player(f.ride.after, f.fremen);
assert.equal(rideAfter.reserves, rideBefore.reserves - 2);
assert.equal(rideAfter.elites!.reserves, rideBefore.elites!.reserves - 1);
assert.equal(rideAfter.forces['polar_sink:0'], (rideBefore.forces['polar_sink:0'] ?? 0) + 2);
assert.equal(rideAfter.spice, rideBefore.spice);
let game = ship(atMovement(f.game, f.native), f.native, 'shrine', 2);
game = consumeSuboids(openBattle(game, f.owner, f.native, 'shrine'), f.owner);
assert.ok(player(game, f.native).faceDancers!.some(c => c.leader === f.trainer && !c.revealed));
const worthless = player(game, f.owner).hand.find(c => c.kind === 'worthless'); assert.ok(worthless);
game = revealPlans(game, [{ actor: f.owner, action: { type: 'battlePlan', leader: f.trainer, dial: 0, support: 0, weapon: worthless.id } },
{ actor: f.native, action: { type: 'battlePlan', leader: leader(game, f.native), dial: 0, support: 0 } }]);
game = advance(game, g => g.decision?.kind === 'battleCards');
assert.equal(game.techTokens!.axlotl.owner, f.native);
policies(game, f.owner, a => a.type === 'decision' && a.discard !== undefined, (after, action) => {
const discarded = action.discard as string[];
assert.equal(player(after, f.owner).hand.some(c => c.id === worthless.id), !discarded.includes(worthless.id));
assert.equal(after.discard.some(c => c.id === worthless.id), discarded.includes(worthless.id));
});
game = step(game, { actor: f.owner, action: { type: 'decision', discard: [worthless.id] } });
game = advance(game, g => g.decision?.kind === 'faceDance');
assert.equal(game.techTokens!.axlotl.owner, f.owner);
assert.equal(player(game, f.owner).hand.some(c => c.id === worthless.id), false);
assert.equal(game.discard.filter(c => c.id === worthless.id).length, 1);
const before = structuredClone(game), home = army(game, f.native, 'homeworld:tleilaxu');
const dance: Action = { type: 'decision', reveal: true, sources: { reserves: 1 }, sector: 0 };
reject(game, f.owner, dance);
reject(game, f.native, { ...dance, sources: { reserves: 3 } });
policies(game, f.native, a => a.type === 'decision' && a.reveal !== undefined, (after, action) => {
const count = action.reveal ? Object.values((action.sources ?? {}) as Record<string, number>).reduce((sum, n) => sum + n, 0) : 0;
assert.equal(player(after, f.owner).leaders.find(l => l.id === f.trainer)!.dead, !!action.reveal);
assert.equal(player(after, f.native).faceDancers!.find(c => c.leader === f.trainer)!.revealed, !!action.reveal);
assert.equal(player(after, f.owner).forces['shrine:0'] ?? 0, action.reveal ? 0 : 2);
assert.equal(player(after, f.native).forces['shrine:0'] ?? 0, count);
assert.deepEqual(after.techTokens, before.techTokens);
});
game = step(JSON.parse(JSON.stringify(game)) as Game, { actor: f.native, action: dance });
assert.equal(player(game, f.owner).forces['shrine:0'] ?? 0, 0);
assert.equal(player(game, f.owner).reserves, player(before, f.owner).reserves + 2);
assert.equal(player(game, f.owner).elites!.reserves, player(before, f.owner).elites!.reserves + 1);
assert.equal(player(game, f.native).forces['shrine:0'], 1);
assert.deepEqual(army(game, f.native, 'homeworld:tleilaxu'), { normal: home.normal - 1, elite: home.elite });
assert.equal(player(game, f.owner).leaders.find(l => l.id === f.trainer)!.dead, true);
assert.equal(game.leaderSkills!.assignments.some(a => a.leader === f.trainer), false);
assert.equal(game.leaderSkills!.deck.filter(c => c === 'suk-graduate').length, 1);
assert.deepEqual(game.techTokens, before.techTokens, 'Face Dance cannot redo the already settled winner reward.');
inventory(game);
});
void test('nested Richese matching pair charges one signed invoice, keeps markers separate until reveal and debits actual Advanced support before Suk and winner Tech', () => {
const f = programme({ family: 'choam', advanced: true, skills: true, discovery: true, tech: true, strongholds: true });
assert.ok(f.firstMarker && f.trainer);
assert.equal(player(f.firstMarker.after, f.owner).spice, player(f.firstMarker.before, f.owner).spice - 1);
assert.equal(player(f.firstMarker.after, f.owner).reserves, player(f.firstMarker.before, f.owner).reserves);
let game = atMovement(f.game, f.owner);
const before = structuredClone(game), request = discoveryPairedNexusE2SkillsCunningAction(game, f.owner);
reject(game, f.owner, { ...request, nexus: 'obsolete-pair' });
reject(game, f.owner, { ...request, revealedToken: request.noField });
reject(game, f.owner, { ...request, smuggler: true });
game = step(game, { actor: f.owner, action: request });
assert.equal(player(game, f.owner).spice, player(before, f.owner).spice, 'Declaration alone does not debit the invoice.');
game = settle(game);
assert.equal(player(game, f.owner).spice, player(before, f.owner).spice - 1);
assert.equal(player(game, f.guild).spice, player(before, f.guild).spice + 1);
assert.equal(player(game, f.owner).forces['shrine:0'], 5);
assert.equal(player(game, f.owner).reserves, player(before, f.owner).reserves - 5);
assert.equal(player(game, f.owner).noField!.deployed!.tokenId, request.noField);
assert.equal(game.nexusCards!.cards!.hands[f.owner], null);
const deployed = structuredClone(game), event = player(game, f.owner).noFieldEvent;
reject(game, f.owner, request);
reject(game, f.owner, { type: 'revealNoField', event: 'obsolete-reveal', token: request.noField });
game = step(JSON.parse(JSON.stringify(game)) as Game, { actor: f.owner,
action: { type: 'revealNoField', event, token: request.noField } });
assert.equal(player(game, f.owner).forces['shrine:0'], 8);
assert.equal(player(game, f.owner).reserves, player(deployed, f.owner).reserves - 3);
assert.equal(player(game, f.owner).spice, player(deployed, f.owner).spice);
assert.equal(player(game, f.guild).spice, player(deployed, f.guild).spice);
game = openBattle(game, f.owner, f.guild, 'shrine');
const beforePlans = structuredClone(game), lostTech = ownedTech(game.techTokens, f.guild);
game = revealPlans(game, [{ actor: f.owner, action: { type: 'battlePlan', leader: f.trainer, dial: 2, support: 2 } },
{ actor: f.guild, action: { type: 'battlePlan', leader: leader(game, f.guild), dial: 0, support: 0 } }]);
assert.equal(player(game, f.owner).spice, player(beforePlans, f.owner).spice, 'Sealed/revealed support is not charged before resolution.');
game = advance(game, g => g.decision?.kind === 'sukRescue');
assert.equal(player(game, f.owner).spice, player(beforePlans, f.owner).spice - 2);
assert.equal(player(game, f.native).spice, player(beforePlans, f.native).spice,
'Native CHOAM income waits for the original skill aftermath; payment has already settled.');
for (const token of lostTech) assert.equal(game.techTokens![token].owner, f.guild);
const d = game.decision; assert.ok(d?.kind === 'sukRescue');
const choice = d.options.findIndex(o => o.normal === 2 && o.elite === 0 && o.kept?.kind === 'normal'); assert.ok(choice >= 0);
const home = army(game, f.owner, 'homeworld:richese'), funds = new Map(game.players.map(p => [p.id, p.spice]));
game = step(game, { actor: f.owner, action: { type: 'decision', event: d.event, choice } });
assert.equal(player(game, f.owner).forces['shrine:0'], 7);
assert.deepEqual(army(game, f.owner, 'homeworld:richese'), { normal: home.normal + 1, elite: 0 });
assert.equal(player(game, f.owner).tanks, 0);
for (const seat of game.players)
assert.equal(seat.spice, funds.get(seat.id)! + (seat.id === f.native ? 1 : 0),
'Rescue does not repeat/refund support; the original pending CHOAM income settles after skill cleanup.');
game = finishBattle(game);
assert.equal(game.lastBattleContext!.winner, f.owner);
assert.equal(player(game, f.guild).tanks, 3);
for (const token of lostTech) assert.equal(game.techTokens![token].owner, f.owner);
inventory(game);
});
void test('real first-turn Arrakeen claim supplies source-local bank support after genuine pairing; held custody and phase-end industry pay only at their original boundaries', () => {
const f = programme({ family: 'choam', advanced: true, discovery: false, skills: true, tech: true, strongholds: true, arrakeenClaim: true });
assert.equal(f.game.strongholdCards!.owners.arrakeen, f.owner);
const actors = new Set([f.owner, f.guild]);
let game = f.game;
while (actors.size) {
game = advance(game, g => g.phase === 5 && clean(g) && actors.has(g.active!));
const actor = game.active!;
if (actor === f.owner) {
const p = player(game, actor), offer = viewGame(game, actor).nexusRicheseCunning; assert.ok(offer && !offer.blocked);
const request: Action = { type: 'ship', territory: 'arrakeen', sector: territory('arrakeen').sectors[0],
noField: p.noField!.tokens.find(t => t.value === 0)!.id, revealedToken: p.noField!.tokens.find(t => t.value === 5)!.id,
event: p.noFieldEvent, nexus: offer.event, allyPayment: 0 };
game = settle(step(game, { actor, action: request }));
game = step(game, { actor, action: { type: 'revealNoField', event: player(game, actor).noFieldEvent, token: request.noField } });
} else game = ship(game, actor, 'arrakeen', 3);
actors.delete(actor);
if (actors.size) game = step(game, { actor, action: { type: 'endMovement' } });
}
const industry = game.techTokens!.heighliners, industryOwner = industry.owner; assert.ok(industryOwner);
const credit = quotePhaseResources(game).credits.find(row => row.kind === 'tech' && row.token === 'heighliners'); assert.ok(credit);
const beforeMovementEnd = structuredClone(game);
game = openBattle(game, f.owner, f.guild, 'arrakeen');
assert.equal(game.techTokens!.heighliners.spice, 0);
assert.equal(player(game, industryOwner).spice, credit.balance);
assert.equal(game.strongholdCards!.owners.arrakeen, f.owner, 'Contest does not transfer a held card before END Mentat.');
assert.equal(viewGame(game, f.owner).battle!.strongholdEffects[f.owner], 'arrakeen');
const beforePlans = structuredClone(game); assert.ok(f.trainer);
game = revealPlans(game, [{ actor: f.owner, action: { type: 'battlePlan', leader: f.trainer, dial: 2, support: 2 } },
{ actor: f.guild, action: { type: 'battlePlan', leader: leader(game, f.guild), dial: 0, support: 0 } }]);
const quote = quotePairedSkillsBattle(game), payment = quote.payments.find(p => p.player === f.owner)!;
assert.equal(payment.ownPayment, 0); assert.equal(payment.bankSupport, 2);
game = advance(game, g => g.decision?.kind === 'sukRescue');
assert.equal(player(game, f.owner).spice, player(beforePlans, f.owner).spice);
assert.equal(player(game, f.native).spice, player(beforePlans, f.native).spice,
'Bank support still earns the pending native income only after skill cleanup.');
const d = game.decision; assert.ok(d?.kind === 'sukRescue');
const choice = d.options.findIndex(o => o.normal === 2 && o.kept?.kind === 'normal'); assert.ok(choice >= 0);
game = finishBattle(step(game, { actor: f.owner, action: { type: 'decision', event: d.event, choice } }));
assert.equal(player(game, f.owner).forces[`arrakeen:${territory('arrakeen').sectors[0]}`], 7);
assert.equal(player(game, f.owner).tanks, 0);
assert.equal(game.strongholdCards!.owners.arrakeen, f.owner);
assert.equal(beforeMovementEnd.techTokens!.heighliners.spice, ownedTech(beforeMovementEnd.techTokens, industryOwner).length);
inventory(game);
});
void test('actual CHOAM Special fuels matching Cunning once; original native payer support is charged at resolution and cannot use held Arrakeen from another source', () => {
const f = programme({ family: 'choam', advanced: true, skills: true, discovery: false, tech: true, strongholds: true, arrakeenClaim: true });
let game = atMovement(f.game, f.native);
const fuel = player(game, f.native).hand.find(c => c.kind === 'special' && c.effect !== 'karama'); assert.ok(fuel);
const action = pairedSkillsChoamCunningRequest(game, f.native, fuel.id);
const before = structuredClone(game);
reject(game, f.native, { ...action, nexus: 'obsolete-choam' });
game = settle(step(game, { actor: f.native, action }));
assert.equal(player(game, f.native).hand.some(c => c.id === fuel.id), false);
assert.equal(game.discard.filter(c => c.id === fuel.id).length, 1);
assert.equal(game.nexusCards!.cards!.hands[f.native], null);
assert.equal(player(game, f.native).spice, player(before, f.native).spice);
reject(game, f.native, action);
game = ship(game, f.native, 'homeworld:guild', 3);
game = openBattle(game, f.native, f.guild, 'homeworld:guild', false);
assert.notEqual(viewGame(game, f.native).battle!.strongholdEffects[f.native], 'arrakeen');
const ownLeader = leader(game, f.native, true), beforePlans = structuredClone(game);
game = revealPlans(game, [{ actor: f.native, action: { type: 'battlePlan', leader: ownLeader, dial: 2, support: 2 } },
{ actor: f.guild, action: { type: 'battlePlan', leader: leader(game, f.guild), dial: 0, support: 0 } }]);
assert.equal(player(game, f.native).spice, player(beforePlans, f.native).spice);
game = finishBattle(game);
assert.equal(game.lastBattleContext!.winner, f.native);
assert.equal(player(game, f.native).tanks, player(beforePlans, f.native).tanks + 2);
assert.deepEqual(army(game, f.native, 'homeworld:guild'), { normal: 1, elite: 0 });
// Collection may already have opened; separate its stronghold/harvest income from support.
const collection = game.phase === 7 && clean(game) ? quoteSpiceCollection(game).receipts.find(r => r.player === f.native) : undefined;
assert.equal(player(game, f.native).spice, player(beforePlans, f.native).spice - 2 +
(collection?.strongholds ?? 0) + (collection?.collected ?? 0));
assert.equal(game.discard.filter(c => c.id === fuel.id).length, 1);
inventory(game);
});
void test('genuine typed Shrine reinforcements make nested Suk precede only residual Cyborg substitution, with matching native Cunning already consumed', () => {
const f = programme({ advanced: true, skills: true, discovery: true, tech: true, strongholds: true });
assert.ok(f.trainer);
const senders = new Set([f.owner, f.native]);
let game = f.game;
while (senders.size) {
game = advance(game, g => g.turn === 2 && g.phase === 5 && clean(g) && senders.has(g.active!));
const actor = game.active!;
game = ship(game, actor, 'shrine', actor === f.owner ? 4 : 3, actor === f.owner ? 2 : 0);
senders.delete(actor);
if (senders.size) game = step(game, { actor, action: { type: 'endMovement' } });
}
assert.equal(player(game, f.owner).forces['shrine:0'], 6);
assert.equal(player(game, f.owner).elites!.forces['shrine:0'], 3);
game = consumeSuboids(openBattle(game, f.owner, f.native, 'shrine'), f.owner);
const home = army(game, f.owner, 'homeworld:ixians'), priorTanks = player(game, f.owner).elites!.tanks;
game = revealPlans(game, [{ actor: f.owner, action: { type: 'battlePlan', leader: f.trainer, dial: 3, support: 0 } },
{ actor: f.native, action: { type: 'battlePlan', leader: leader(game, f.native), dial: 0, support: 0 } }]);
game = advance(game, g => g.decision?.kind === 'sukRescue');
const d = game.decision; assert.ok(d?.kind === 'sukRescue');
const choice = d.options.findIndex(o => o.normal === 0 && o.elite === 1 && o.kept?.kind === 'elite'); assert.ok(choice >= 0);
game = step(game, { actor: f.owner, action: { type: 'decision', event: d.event, choice } });
game = advance(game, g => g.decision?.kind === 'ixSubstitution');
assert.equal(player(game, f.owner).elites!.tanks, priorTanks + 2);
assert.equal(player(game, f.owner).forces['shrine:0'], 4);
assert.equal(player(game, f.owner).elites!.forces['shrine:0'], 1);
assert.deepEqual(army(game, f.owner, 'homeworld:ixians'), home, 'The kept nested Cyborg is not also returned to native reserves.');
assert.deepEqual(game.pendingIxSubstitution!.losses, { 'shrine:0': 2 });
const before = structuredClone(game);
const action: Action = { type: 'decision', sources: { 'shrine:0': 1 }, recover: { 'shrine:0': 1 } };
reject(game, f.owner, { ...action, recover: { 'shrine:0': 3 } });
reject(game, f.owner, { ...action, sources: { 'homeworld:ixians': 1 } });
game = step(JSON.parse(JSON.stringify(game)) as Game, { actor: f.owner, action });
game = advance(game, g => !g.pendingIxSubstitution);
assert.equal(player(game, f.owner).forces['shrine:0'], 4);
assert.equal(player(game, f.owner).elites!.forces['shrine:0'], 2);
assert.equal(player(game, f.owner).elites!.tanks, player(before, f.owner).elites!.tanks - 1);
assert.equal(player(game, f.owner).tanks, player(before, f.owner).tanks);
assert.equal(player(game, f.owner).spice, player(before, f.owner).spice);
assert.deepEqual(army(game, f.owner, 'homeworld:ixians'), home);
game = finishBattle(game);
assert.equal(game.lastBattleContext!.winner, f.owner);
assert.equal(player(game, f.native).tanks, 3);
assert.equal(player(game, f.native).faceDancers!.find(c => c.leader === f.trainer)!.revealed, false);
inventory(game);
});
void test('no-Skills native Richese pair at a revealed nested site is capped by reserves spent through four actual earlier shipments, not virtual materialization', () => {
const f = programme({ family: 'choam', advanced: true, skills: false, discovery: true, tech: true, strongholds: true });
let game = f.game;
// Keep the genuinely drawn matching singleton while four ordinary markers
// consume 5 + 3 + 5 + 3 physical native counters through their actual seats.
for (const [index, value] of [5, 3, 5, 3].entries()) {
const turn = index + 2;
if (turn > 2) {
game = advance(game, g => g.turn === turn && g.phase === 1 && clean(g));
// Only unused land faces are conserved; held Nexus, played worms and
// custody remain untouched. Two owned strongholds do not win the game.
const lands = game.spiceDeck.filter(c => 'territory' in c && !c.discovery).slice(0, 2);
for (const [position, card] of lands.entries())
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]);
}
game = atMovement(game, f.owner, turn);
const destination = ['sietch_tabr', 'habbanya_ridge_sietch'].map(id => territory(id))
.find(t => !t.sectors.includes(game.storm) && game.players.filter(p => p.id !== f.owner)
.every(p => Object.entries(p.forces).every(([key, amount]) => !amount || !key.startsWith(`${t.id}:`)))); assert.ok(destination);
const p = player(game, f.owner), token = p.noField!.tokens.find(t => t.value === value)!;
game = settle(step(game, { actor: f.owner, action: { type: 'ship', territory: destination.id,
sector: destination.sectors[0], noField: token.id, event: p.noFieldEvent, allyPayment: 0 } }));
game = step(game, { actor: f.owner, action: { type: 'revealNoField', token: token.id, event: player(game, f.owner).noFieldEvent } });
assert.equal(game.nexusCards!.cards!.hands[f.owner], 'richese');
inventory(game);
}
game = advance(game, g => g.turn === 6 && g.phase === 1 && clean(g));
const lands = game.spiceDeck.filter(c => 'territory' in c && !c.discovery).slice(0, 2);
for (const [position, card] of lands.entries())
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]);
game = advance(game, g => g.turn === 6 && g.phase === 2 && clean(g));
game = step(game, { actor: f.owner, action: { type: 'charity' } });
game = atMovement(game, f.owner, 6);
assert.equal(player(game, f.owner).reserves, 4);
const p = player(game, f.owner), offer = viewGame(game, f.owner).nexusRicheseCunning; assert.ok(offer && !offer.blocked);
const hidden = p.noField!.tokens.find(t => t.value === 5)!;
const action: Action = { type: 'ship', territory: 'shrine', sector: 0, allyPayment: 0,
noField: hidden.id, revealedToken: p.noField!.tokens.find(t => t.value === 0)!.id,
event: p.noFieldEvent, nexus: offer.event };
const before = structuredClone(game);
game = settle(step(game, { actor: f.owner, action }));
assert.equal(player(game, f.owner).forces['shrine:0'] ?? 0, 0);
assert.equal(player(game, f.owner).reserves, 4);
assert.equal(player(game, f.owner).noField!.deployed!.tokenId, hidden.id);
assert.equal(game.nexusCards!.cards!.hands[f.owner], null);
assert.equal(player(game, f.owner).spice, player(before, f.owner).spice - 1);
assert.equal(player(game, f.guild).spice, player(before, f.guild).spice + 1);
reject(game, f.owner, action);
const shipped = structuredClone(game);
game = step(JSON.parse(JSON.stringify(game)) as Game, { actor: f.owner,
action: { type: 'revealNoField', token: hidden.id, event: player(game, f.owner).noFieldEvent } });
assert.equal(player(game, f.owner).forces['shrine:0'], 4);
assert.equal(player(game, f.owner).spice, player(shipped, f.owner).spice);
assert.equal(player(game, f.guild).spice, player(shipped, f.guild).spice);
assert.equal(player(game, f.owner).reserves, 0);
assert.equal(player(game, f.owner).noField!.lastShipped, hidden.id);
inventory(game);
});
@@ -0,0 +1,405 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, viewGame,
type Action, type Game,
} from '../game/engine';
import { DIFFICULTIES } from '../game/bot-profiles';
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
import { ownedTech } from '../game/tech-tokens';
import { strongholdControllers } from '../game/stronghold-cards';
import { ecazOccupancyRelation } from '../game/ecaz-occupy';
import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { nexusEcazDukeAction } from '../game/nexus-ecaz-duke-options';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import {
advanceStandaloneHomeworldNexus as advance, assertStandaloneHomeworldNexusCustody as custody,
buyStandaloneHomeworldMoritaniArmament as buyArmament, openStandaloneHomeworldGuildDeath as guildDeathBattle,
createStandaloneHomeworldEcazCunning as ecazProgramme,
createStandaloneHomeworldNexusClosing as closing, createStandaloneHomeworldNexusDiscovery as discovery,
createStandaloneHomeworldNexusSetup as setup,
nextStandaloneHomeworldNexusStep as next, openStandaloneHomeworldMoritaniDeath as deathBattle,
orderStandaloneHomeworldNexusSpice as orderSpice, reloadStandaloneHomeworldNexus as reload,
revealStandaloneHomeworldNexusPlans as reveal, settleStandaloneHomeworldNexus as settle,
standaloneHomeworldNexusClean as clean, standaloneHomeworldNexusPlayer as player,
standaloneHomeworldNexusPolicy as policy, standaloneHomeworldNexusPool as pool,
stepStandaloneHomeworldNexus as step, type StandaloneHomeworldNexusStep,
} from './fixture-homeworld-standalone-nexus';
function reject(g: Game, actor: string, action: Action, reason?: RegExp): void {
const before = structuredClone(g);
if (reason) assert.throws(() => applyAction(g, actor, action), reason);
else assert.throws(() => applyAction(g, actor, action));
assert.deepEqual(g, before);
}
/** Every difficulty acts at a genuine window and demonstrates its physical
* consumer transition. Applying an action without throwing is not the proof. */
function legalPolicies(g: Game, actor: string, consume: (after: Game, action: Action) => void): void {
for (const difficulty of DIFFICULTIES) {
const offered = policy(g, actor, difficulty); assert.ok(offered[0], `${difficulty} needs an actual owned choice.`);
const after = applyAction(reload(g), actor, offered[0]);
consume(after, offered[0]); custody(after);
}
}
function finishSettlement(g: Game, actions?: StandaloneHomeworldNexusStep[]): Game {
for (let n = 0; n < 80; n++) {
const boundary = settle(g, actions); g = boundary.game;
if (boundary.kind === 'settled') return g;
const d = g.decision; assert.ok(d);
let action: Action;
if (d.kind === 'battleLosses') action = { type: 'decision', choice: 0 };
else if (d.kind === 'battleCards') action = { type: 'decision', discard: [] };
else if (d.kind === 'sukRescue') action = { type: 'decision', event: d.event, choice: 0 };
else if (d.kind === 'moritaniAssassinate') action = { type: 'decision', event: d.event, decline: true };
else if (d.kind === 'techToken') {
const target = d.choices[0];
assert.equal(g.battle, null, 'Original battle disposal precedes mandatory winner Tech.');
assert.ok(!g.pendingSukRescue && !g.pendingWinnerDiscards);
for (const entry of g.lastBattleContext!.mandatoryDiscard?.entries ?? [])
assert.ok(g.discard.some(c => c.id === entry.card), 'Played cards are physically cleaned before the reward.');
reject(g, d.loser, { type: 'decision', token: target });
reject(g, d.player, { type: 'decision', decline: true });
legalPolicies(g, d.player, (after, selected) => {
const token = selected.token as keyof NonNullable<Game['techTokens']>;
assert.ok(d.choices.includes(token)); assert.equal(after.techTokens![token].owner, d.player);
});
action = { type: 'decision', token: target };
} else throw Error(`Unexpected original settlement boundary ${d.kind}`);
g = step(g, { actor: d.player, action }, actions);
}
throw Error('Original physical settlement did not finish.');
}
void test('a genuine native closing deal and living unclaimed Ecaz Cunning coexist with real native Wallach custody, never Intrusion or Homeworld Occupy', () => {
const f = ecazProgramme({ discovery: true });
assert.equal(f.firstStorm.techTokens && ownedTech(f.firstStorm.techTokens, f.owner).length, 1);
assert.deepEqual(f.firstMentat.after.strongholdCards!.owners, strongholdControllers(f.firstMentat.before.players, false));
assert.equal(f.afterSetup.dukeVidal!.controller, null);
assert.equal(f.afterSetup.dukeVidal!.leader.dead, false);
assert.equal(player(f.alliance, f.owner).ally, null);
assert.equal(f.draw.before.nexusCards!.cards!.hands[f.owner], null);
assert.equal(f.draw.after.nexusCards!.cards!.hands[f.owner], 'ecaz');
assert.ok(!f.draw.after.nexusCards!.cards!.deck.includes('ecaz'));
legalPolicies(f.draw.before, f.owner, after => {
assert.equal(after.nexusCards!.cards!.hands[f.owner], 'ecaz');
assert.ok(!after.nexusCards!.cards!.deck.includes('ecaz'));
});
reject(f.draw.before, f.opponent, f.draw.step.action);
reject(f.draw.after, f.owner, f.draw.step.action);
const shipper = player(f.arrival.before, f.owner), arrived = player(f.arrival.settled, f.owner);
const sources = nativeShipmentSources(viewGame(f.arrival.before, f.owner), 1, 0); assert.ok(sources);
const choice = homeworldShipmentChoice(viewGame(f.arrival.before, f.owner), f.location,
Object.fromEntries(Object.entries(sources).filter(([, group]) => group.normal + group.elite > 0)));
assert.equal(arrived.spice, shipper.spice - choice.cost);
assert.equal(arrived.reserves, shipper.reserves - 1);
assert.equal(pool(f.arrival.settled, f.owner, f.location).normal, 1);
assert.deepEqual(pool(f.arrival.settled, f.opponent, f.location), pool(f.arrival.before, f.opponent, f.location));
assert.ok(!f.arrival.settled.pendingAmbassador && !f.arrival.settled.pendingTerrorEntry);
assert.notEqual(f.arrival.settled.decision?.kind, 'intrusion');
assert.ok(!Object.keys(arrived.forces).some(key => key.startsWith('homeworld:')));
assert.ok(!Object.keys(player(f.arrival.settled, f.opponent).advisors ?? {}).some(key => key.startsWith('homeworld:')));
assert.equal(f.game.battle!.territory, f.location);
custody(f.arrival.settled);
const action = nexusEcazDukeAction(viewGame(f.cunning.before, f.owner)); assert.ok(action);
reject(f.cunning.before, f.opponent, action);
reject(f.cunning.before, f.owner, { ...action, event: 'stale' });
legalPolicies(f.cunning.before, f.owner, after => {
assert.equal(after.dukeVidal!.controller, f.owner);
assert.equal(after.nexusCards!.cards!.hands[f.owner], null);
assert.equal(after.nexusCards!.cards!.discard.filter(c => c === 'ecaz').length, 1);
});
assert.equal(f.cunning.after.dukeVidal!.controller, f.owner);
assert.equal(f.cunning.after.dukeVidal!.source, 'ecazNexus');
assert.equal(f.cunning.after.dukeVidal!.leader.dead, false);
assert.ok(!f.cunning.after.leaderSkills!.assignments.some(a => a.leader === DUKE_VIDAL_ID));
assert.ok(player(f.cunning.after, f.owner).leaders.every(l => l.id !== DUKE_VIDAL_ID));
reject(f.cunning.after, f.owner, action);
reject(f.game, f.owner, action, /interaction|unavailable|current/i);
const revealed = reveal(f.game, f.plans, f.actions);
assert.equal(player(revealed, f.opponent).spice, player(f.game, f.opponent).spice,
'The real sealed plan does not debit dialed support at reveal.');
const done = finishSettlement(revealed, f.actions);
assert.equal(done.lastBattleContext!.winner, f.opponent);
assert.equal(player(done, f.opponent).spice, player(revealed, f.opponent).spice - 3,
'Free native strength never pays for three dialed support.');
assert.equal(player(done, f.opponent).tanks, player(revealed, f.opponent).tanks + 3);
assert.equal(pool(done, f.opponent, f.location).normal, pool(revealed, f.opponent, f.location).normal - 3);
assert.equal(player(done, f.owner).tanks, player(revealed, f.owner).tanks + 1);
assert.equal(pool(done, f.owner, f.location).normal, 0);
assert.equal(done.dukeVidal!.leader.dead, false);
assert.equal(done.leaderSkills!.assignments.find(a => a.owner === f.owner)!.leader, f.trainer);
const token = ownedTech(revealed.techTokens, f.owner)[0]; assert.ok(token);
assert.equal(done.techTokens![token].owner, f.opponent);
custody(done);
// The genuine next Nexus changes the alliance, not the physical identity of
// either counter pool. Paid Arrakis shipments may now occupy one territory;
// those same seats still cannot use allied native-home shipment as Occupy.
let g = advance(done, s => s.turn === 3 && s.phase === 1 && clean(s), f.actions);
assert.equal(g.dukeVidal!.controller, null);
orderSpice(g, true);
g = advance(g, s => s.nexus && !s.spiceWindow && !s.spiceResolution && clean(s), f.actions);
const guild = g.players.find(p => p.faction === 'guild')!.id;
g = step(g, { actor: guild, action: { type: 'alliance', target: null } }, f.actions);
for (const [actor, target] of [[f.owner, guild], [guild, f.owner]])
g = step(g, { actor, action: { type: 'alliance', target } }, f.actions);
assert.equal(ecazOccupancyRelation(g.players, f.owner, guild, { kind: 'territory', id: 'polar_sink' }), 'ecazAlliance');
assert.equal(ecazOccupancyRelation(g.players, f.owner, guild, { kind: 'homeworld', id: 'homeworld:guild' }), 'different');
const shipped: Record<string, boolean> = {};
for (let n = 0; n < 240; n++) {
if (shipped[f.owner] && shipped[guild] && g.phase >= 6 && clean(g)) break;
if (g.phase === 5 && clean(g) && g.active && !shipped[g.active] && [f.owner, guild].includes(g.active)) {
const actor = g.active, view = viewGame(g, actor), own = player(g, actor);
const reserves = nativeShipmentSources(view, 1, 0); assert.ok(reserves);
if (actor === f.owner) {
const alliedHome = homeworldShipmentChoice(view, 'homeworld:guild', reserves);
assert.equal(alliedHome.action, null);
assert.ok(alliedHome.blocked);
}
const before = reload(g);
g = step(g, { actor, action: { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1,
elite: 0, allyPayment: 0, homeworldSources: reserves } }, f.actions);
g = advance(g, s => !s.pendingShipment && clean(s), f.actions);
assert.equal(player(g, actor).reserves, own.reserves - 1);
assert.equal(player(g, actor).forces['polar_sink:0'], (player(before, actor).forces['polar_sink:0'] ?? 0) + 1);
assert.ok(player(g, actor).spice < own.spice);
shipped[actor] = true;
} else {
const owned = next(g); assert.ok(owned); g = step(g, owned, f.actions);
}
}
assert.ok(shipped[f.owner] && shipped[guild]);
assert.equal(player(g, f.owner).forces['polar_sink:0'], 1);
assert.equal(player(g, guild).forces['polar_sink:0'], 1);
assert.equal(g.battle, null, 'Original allied Arrakis Occupy is not a battle between those two seats.');
custody(g);
});
void test('original Moritani private rescue → assassination → exact death Skill return → card cleanup/Tech → private replacement precedes five actual own deaths and paid own revival', () => {
const f = closing({ faction: 'moritani', advanced: true, discovery: true, tech: true, strongholds: true });
assert.equal(f.draw.after.nexusCards!.cards!.hands[f.owner], 'moritani');
const target = f.afterSetup.leaderSkills!.assignments.find(a => a.owner === f.opponent)!.leader;
assert.equal(player(f.afterSetup, f.opponent).leaders.find(l => l.id === target)!.name, 'Master Bewt');
assert.ok(player(f.afterSetup, f.owner).traitors.includes(target));
const firstDisc = player(f.afterSetup, f.owner).leaders.find(l => l.id !== f.trainer)!.id;
const discs = [firstDisc, ...player(f.afterSetup, f.opponent).leaders.map(l => l.id).filter(id => id !== target)];
const purchased = buyArmament(f, f.game);
assert.equal(player(purchased.game, f.owner).spice, player(purchased.before, f.owner).spice - purchased.bought.length);
assert.ok(player(purchased.game, f.owner).hand.some(c => c.id === purchased.weapon));
assert.ok(player(purchased.game, f.owner).hand.some(c => c.id === purchased.defense));
let g = purchased.game;
for (const [index, disc] of discs.entries()) {
const battle = index === 0 ? deathBattle(f, g, disc, true)
: guildDeathBattle(f, g, disc, purchased.weapon, purchased.defense);
const visitor = index === 0 ? f.owner : f.opponent;
assert.equal(player(battle.settledArrival, visitor).reserves, player(battle.arrival.before, visitor).reserves - 1);
assert.equal(pool(battle.settledArrival, visitor, battle.location).normal, 1);
assert.ok(player(battle.settledArrival, visitor).spice < player(battle.arrival.before, visitor).spice);
const revealed = reveal(battle.game, battle.plans, f.actions);
assert.equal(player(revealed, f.opponent).spice, player(battle.game, f.opponent).spice);
g = revealed;
if (index === 0) {
let boundary = settle(g, f.actions);
for (let n = 0; boundary.kind === 'losses' || boundary.kind === 'rescue'; n++) {
assert.ok(n < 12);
const d = boundary.game.decision; assert.ok(d);
g = step(boundary.game, { actor: d.player, action: { type: 'decision', ...(d.kind === 'sukRescue' ? { event: d.event } : {}), choice: 0 } }, f.actions);
boundary = settle(g, f.actions);
}
assert.equal(boundary.kind, 'assassination'); g = boundary.game;
assert.equal(g.lastBattleContext!.sukRescue!.completed, true);
assert.deepEqual(g.lastBattleContext!.sukRescue!.physical!.saved, { normal: 1, elite: 0 });
assert.equal(player(g, f.opponent).tanks, player(revealed, f.opponent).tanks);
assert.equal(pool(g, f.opponent, 'homeworld:guild').normal, pool(revealed, f.opponent, 'homeworld:guild').normal,
'Native rescue saves its real dialed casualty back at the SAME home.');
assert.equal(player(g, f.opponent).spice, player(revealed, f.opponent).spice - 1 +
player(revealed, f.owner).leaders.find(l => l.id === disc)!.strength,
'Ordinary paid support and the killed opposing-disc reward both settle before assassination.');
assert.equal(player(g, f.owner).spice, player(revealed, f.owner).spice,
'A native-home loss pays no Arrakis Worthless income; the separate assassination bounty is still pending.');
assert.equal(player(g, f.owner).leaders.find(l => l.id === disc)!.dead, true);
assert.equal(player(g, f.opponent).leaders.find(l => l.id === target)!.dead, false);
assert.notEqual(revealed.battle!.plans[f.opponent].leader, target);
const d = g.decision; assert.ok(d?.kind === 'moritaniAssassinate');
const bounty = player(g, f.opponent).leaders.find(l => l.id === target)!.strength;
const reserve = [...g.traitorReserve!], held = [...player(g, f.owner).traitors], before = reload(g);
const pendingToken = ownedTech(g.techTokens, f.owner)[0]; assert.ok(pendingToken);
assert.equal(g.techTokens![pendingToken].owner, f.owner, 'Winner reward has not bypassed the private aftermath.');
const action: Action = { type: 'decision', event: d.event, card: target };
reject(g, f.opponent, action);
reject(g, f.owner, { ...action, event: 'stale' });
reject(g, f.owner, { ...action, card: revealed.battle!.plans[f.opponent].leader });
legalPolicies(g, f.owner, after => {
assert.equal(player(after, f.opponent).leaders.find(l => l.id === target)!.dead, true);
assert.equal(player(after, f.owner).spice, player(before, f.owner).spice + bounty);
assert.equal(after.leaderSkills!.assignments.some(a => a.leader === target), false);
assert.equal(after.leaderSkills!.deck.filter(s => s === 'suk-graduate').length, 1);
});
g = step(g, { actor: f.owner, action }, f.actions);
assert.equal(player(g, f.opponent).leaders.find(l => l.id === target)!.deaths,
player(before, f.opponent).leaders.find(l => l.id === target)!.deaths + 1);
assert.deepEqual(g.homeworlds!.custody, before.homeworlds!.custody);
assert.equal(player(g, f.opponent).tanks, player(before, f.opponent).tanks);
assert.equal(g.leaderSkills!.assignments.some(a => a.leader === target), false);
assert.equal(g.leaderSkills!.deck.filter(s => s === 'suk-graduate').length, 1);
g = finishSettlement(g, f.actions);
const worthless = battle.plans[0].action.weapon as string; assert.ok(worthless);
assert.ok(g.discard.some(c => c.id === worthless));
assert.ok(!player(g, f.owner).hand.some(c => c.id === worthless));
const token = ownedTech(before.techTokens, f.owner)[0]; assert.ok(token);
assert.equal(g.techTokens![token].owner, f.opponent);
assert.equal(player(g, f.owner).spice, player(before, f.owner).spice + bounty);
g = advance(g, s => s.phase === 8, f.actions);
const replacement = g.moritaniAssassinate!.opportunities.at(-1)!;
assert.equal(replacement.stage, 'replaced'); assert.equal(replacement.replacement, reserve[0]);
assert.deepEqual(g.traitorReserve, reserve.slice(1));
assert.deepEqual(player(g, f.owner).traitors, [...held.filter(c => c !== target), reserve[0]]);
assert.equal('replacement' in viewGame(g, f.opponent).moritaniAssassinate!.history.at(-1)!, false);
} else g = finishSettlement(g, f.actions);
const victim = index === 0 ? f.owner : f.opponent, winner = index === 0 ? f.opponent : f.owner;
assert.equal(g.lastBattleContext!.winner, winner);
assert.equal(player(g, f.opponent).leaders.filter(l => l.dead).length, index + 1);
assert.equal(player(g, victim).leaders.find(l => l.id === disc)!.deaths,
player(battle.game, victim).leaders.find(l => l.id === disc)!.deaths + 1);
assert.equal(player(g, victim).tanks, player(revealed, victim).tanks + 1);
assert.equal(pool(g, victim, battle.location).normal, 0);
if (index > 0) {
assert.equal(player(g, f.owner).leaders.filter(l => l.dead).length, 1,
'The actual native winner stays alive; auctioned Shield stops the original Guild projectile before its loser cleanup.');
assert.equal(player(g, f.owner).tanks, player(revealed, f.owner).tanks + 1);
assert.equal(pool(g, f.owner, battle.location).normal, pool(revealed, f.owner, battle.location).normal - 1);
assert.equal(player(g, f.owner).spice, player(revealed, f.owner).spice - 1 +
player(revealed, f.opponent).leaders.find(l => l.id === disc)!.strength,
'The actual native winner pays support and receives the ordinary opposing-leader death reward once.');
for (const card of [purchased.weapon, purchased.defense]) {
assert.ok(player(g, f.owner).hand.some(c => c.id === card));
assert.ok(!g.discard.some(c => c.id === card));
}
if (battle.plans[1].action.weapon) {
assert.ok(g.discard.some(c => c.id === battle.plans[1].action.weapon));
assert.ok(!player(g, f.opponent).hand.some(c => c.id === battle.plans[1].action.weapon));
}
} else {
const weapon = battle.plans[1].action.weapon as string;
assert.equal(player(g, f.opponent).hand.filter(c => c.id === weapon).length, 1);
assert.ok(!g.discard.some(c => c.id === weapon));
}
custody(g);
const turn = g.turn;
g = advance(g, s => s.turn === turn + 1 && s.phase === 1 && clean(s), f.actions);
orderSpice(g);
g = advance(g, s => s.phase === 4 && clean(s), f.actions);
if (index < discs.length - 1) reject(g, f.opponent, { type: 'reviveLeader', leader: target }, /All leaders must die/);
}
assert.equal(player(g, f.opponent).leaders.filter(l => l.dead).length, 5);
assert.equal(g.leaderSkills!.assignments.some(a => a.owner === f.opponent), false);
assert.equal(g.leaderSkills!.deck.filter(s => s === 'suk-graduate').length, 1);
assert.equal(g.leaderSkills!.assignments.find(a => a.owner === f.owner)!.leader, f.trainer);
const before = reload(g), dead = player(g, f.opponent).leaders.find(l => l.id === target)!;
reject(g, f.owner, { type: 'reviveLeader', leader: target });
g = step(g, { actor: f.opponent, action: { type: 'reviveLeader', leader: target } }, f.actions);
g = advance(g, s => !!s.leaderSkills!.offers[f.opponent], f.actions);
assert.equal(player(g, f.opponent).spice, player(before, f.opponent).spice - dead.strength);
assert.equal(player(g, f.opponent).leaders.find(l => l.id === target)!.dead, false);
assert.equal(player(g, f.opponent).leaders.filter(l => l.dead).length, 4);
assert.equal(viewGame(g, f.owner).leaderSkills!.offer, null);
assert.equal(viewGame(g, f.opponent).leaderSkills!.offer!.cards.length, 0);
const event = viewGame(g, f.opponent).leaderSkills!.offer!.event;
legalPolicies(g, f.opponent, after => {
assert.equal(viewGame(after, f.opponent).leaderSkills!.offer!.cards.length, 2);
assert.equal(player(after, f.opponent).spice, player(g, f.opponent).spice);
});
g = step(g, { actor: f.opponent, action: { type: 'leaderSkill', event, mode: 'draw' } }, f.actions);
const offer = viewGame(g, f.opponent).leaderSkills!, skill = offer.offer!.cards.find(s => !offer.unavailableSkills?.[s]); assert.ok(skill);
reject(g, f.owner, { type: 'leaderSkill', event, skill, leader: target });
reject(g, f.opponent, { type: 'leaderSkill', event, skill, leader: DUKE_VIDAL_ID });
g = step(g, { actor: f.opponent, action: { type: 'leaderSkill', event, skill, leader: target } }, f.actions);
assert.equal(g.leaderSkills!.assignments.find(a => a.owner === f.opponent)!.leader, target);
assert.equal(player(g, f.opponent).spice, player(before, f.opponent).spice - dead.strength);
assert.equal(g.nexusCards!.cards!.hands[f.owner], 'moritani'); custody(g);
});
void test('Basic native Moritani loss keeps normal same-home Suk but never gains Advanced private assassination', () => {
const f = closing({ faction: 'moritani', advanced: false, discovery: false, tech: true });
const disc = player(f.afterSetup, f.owner).leaders.find(l => l.id !== f.trainer)!.id;
const battle = deathBattle(f, f.game, disc, true), revealed = reveal(battle.game, battle.plans, f.actions);
const done = finishSettlement(revealed, f.actions);
assert.equal(done.lastBattleContext!.winner, f.opponent);
assert.equal(done.lastBattleContext!.sukRescue!.completed, true);
assert.deepEqual(done.lastBattleContext!.sukRescue!.physical!.saved, { normal: 1, elite: 0 });
assert.equal(pool(done, f.opponent, 'homeworld:guild').normal, pool(revealed, f.opponent, 'homeworld:guild').normal);
const target = done.leaderSkills!.assignments.find(a => a.owner === f.opponent)!.leader;
assert.equal(player(done, f.opponent).leaders.find(l => l.id === target)!.dead, false);
assert.equal(done.leaderSkills!.assignments.find(a => a.leader === target)!.skill, 'suk-graduate');
custody(done);
});
for (const faction of ['ecaz', 'moritani'] as const) void test(`${faction} original Discovery shipment/reveal/free nested entry keeps all native reserve identity while all12 Nexus stay undealt`, () => {
const f = discovery({ faction, advanced: false, roster: [faction, 'guild', 'emperor', 'beneGesserit'], tech: true });
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(pool(f.afterShipment, f.owner, `homeworld:${faction}`).normal, player(f.afterShipment, f.owner).reserves);
assert.equal(f.revealed.discoveries!.tokens.find(t => t.id === f.token)!.revealedTurn, 1);
assert.equal(player(f.entry.before, f.owner).forces['gara_kulon:8'], 2);
assert.equal(player(f.entry.after, f.owner).forces['gara_kulon:8'] ?? 0, 0);
assert.equal(player(f.entry.after, f.owner).forces['cistern:0'], 2);
assert.equal(player(f.entry.after, f.owner).spice, player(f.entry.before, f.owner).spice);
assert.equal(player(f.entry.after, f.owner).reserves, player(f.entry.before, f.owner).reserves);
assert.deepEqual(f.entry.after.homeworlds!.custody, f.entry.before.homeworlds!.custody);
assert.ok(Object.values(f.entry.after.nexusCards!.cards!.hands).every(c => c === null));
const action = discoveryEntryMoveAction(viewGame(f.entry.before, f.owner), viewGame(f.entry.before, f.owner).discoveryEntry!.sources); assert.ok(action);
reject(f.entry.before, f.opponent, action);
reject(f.entry.after, f.owner, action);
legalPolicies(f.entry.before, f.owner, (after, offered) => {
assert.equal(player(after, f.owner).spice, player(f.entry.before, f.owner).spice);
assert.equal(player(after, f.owner).reserves, player(f.entry.before, f.owner).reserves);
if (offered.groups) assert.equal(player(after, f.owner).forces['cistern:0'], 2);
else assert.equal(player(after, f.owner).forces['gara_kulon:8'], 2);
assert.notEqual(after.decision?.kind, 'discoveryEntry');
});
custody(f.entry.after);
});
void test('original supplied offers keep human choices while Duke training, Advanced Harkonnen and paired E3 reject before custody conversion', () => {
const original = createGame('SUPPLIEDHWE3NX', newPlayer('native-original', 'Original native', 'ecaz'), false, ['ecaz']);
joinGame(original, newPlayer('guild-original', 'Original Guild', 'guild'));
const f = setup({ initial: original });
const trained = f.afterSetup.leaderSkills!.assignments.find(a => a.owner === f.owner)!;
const own = viewGame(f.offered, f.owner).leaderSkills!;
assert.ok(own.offer!.cards.includes(trained.skill));
assert.ok(own.eligibleLeaders.some(l => l.id === trained.leader));
reject(f.offered, f.owner, { type: 'leaderSkill', event: own.offer!.event, skill: trained.skill, leader: DUKE_VIDAL_ID });
const unsupported = own.offer!.cards.includes('mentat') ? 'warmaster' : 'mentat';
if (!own.offer!.cards.includes(unsupported)) reject(f.offered, f.owner,
{ type: 'leaderSkill', event: own.offer!.event, skill: unsupported, leader: trained.leader });
legalPolicies(f.offered, f.owner, after => {
const assignment = after.leaderSkills!.assignments.find(a => a.owner === f.owner); assert.ok(assignment);
assert.ok(own.offer!.cards.includes(assignment.skill));
assert.ok(player(after, f.owner).leaders.some(l => l.id === assignment.leader));
assert.notEqual(assignment.leader, DUKE_VIDAL_ID);
});
const resumed = setup({ initial: reload(f.offered) });
const actual = resumed.afterSetup.leaderSkills!.assignments.find(a => a.owner === f.owner)!;
assert.ok(own.offer!.cards.includes(actual.skill));
assert.equal(player(resumed.afterSetup, f.owner).reserves, 14);
assert.equal(pool(resumed.afterSetup, f.owner, 'homeworld:ecaz').normal, 14); custody(resumed.afterSetup);
assert.throws(() => setup({ initial: f.afterSetup }), /lobby|ORIGINAL|setup/);
for (const [advanced, factions] of [
[true, ['ecaz', 'guild', 'harkonnen']],
[true, ['moritani', 'guild', 'harkonnen']],
[true, ['ecaz', 'guild', 'moritani']],
] as const) {
let g = createGame('ORIGINALSTANDALONEGUARD', newPlayer(factions[0], factions[0], factions[0]), advanced, ['ecaz']);
for (const faction of factions.slice(1)) joinGame(g, newPlayer(faction, faction, faction));
g = applyAction(g, g.host, { type: 'homeworlds', enabled: true });
g.nexusCards = { cards: null, phase: null };
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
const before = structuredClone(g);
assert.throws(() => initializeLeaderSkillsGameForAudit(g)); assert.deepEqual(g, before);
assert.equal(g.homeworlds!.custody, null);
}
});
+1 -16
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { MOBILE_STRONGHOLD, territory } from '../game/board';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
@@ -267,18 +267,3 @@ void test('representative native bot Face Dance consumes only actual paired rese
custody(after, f.afterSetup);
});
void test('original authenticated lobby and undealt CLI setup continue to actor-bound real Nexus Cunning without redealing', () => {
const lobby = createGame('PAIREDIXAUTH', newPlayer('authenticated-ix', 'Ix', 'ixians'), true, ['ix']);
for (const [id, faction] of [['authenticated-tl', 'tleilaxu'], ['authenticated-guild', 'guild'], ['authenticated-emperor', 'emperor']] as const)
joinGame(lobby, newPlayer(id, faction, faction));
const first = createPairedIxNexusModulesFixture({ initial: lobby, tech: true, strongholds: false });
const continued = createPairedIxNexusModulesFixture({ initial: first.initial });
assert.deepEqual(player(continued.afterSetup, first.ixians).hand, player(first.afterSetup, first.ixians).hand,
'The original Ix setup offer and actual first deal are continued, not replaced');
const empowered = useSuboids(continued);
const revealed = revealPairedIxNexusModulesBattle(empowered, continued.planActions);
assert.equal(quoteStrongholdFactionsBattle(revealed).winner, 'authenticated-ix');
assert.equal(revealed.nexusSuboidHistory![0].owner, 'authenticated-ix');
assert.equal(player(revealed, 'authenticated-tl').ally, null);
custody(revealed, continued.afterSetup);
});