374 lines
25 KiB
TypeScript
374 lines
25 KiB
TypeScript
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;
|