Files
Dune/tests/fixture-mixed-e3-ecaz-nexus.ts
T

502 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict';
import { applyAction, createGame, initializeLeaderSkillsGameForAudit, initializeNexusGameForAudit,
joinGame, newPlayer, viewGame } from '../game/engine';
import type { Action, Game, Player } from '../game/engine';
import type { FactionId } from '../game/catalog';
import type { Difficulty } from '../game/bot-profiles';
import { botActions } from '../game/bots';
import { spiceDeck, treacheryDeck } from '../game/cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import type { SpiceCard } from '../game/cards';
import type { HarassWithdrawSelection } from '../game/harass-withdraw';
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import type { LeaderSkillId } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
import { traitorDeck } from '../game/traitors';
import { richeseCards } from '../game/richese-cards';
import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_SPICE_CARDS, validateDiscoveryState } from '../game/discoveries';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import { greatMakerRideAction } from '../game/great-maker-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 { homeworldCombatLocation } from '../game/homeworld-combat';
import { quoteBattleResolution } from '../game/battle-resolution-quote';
import type { BattleResolutionQuote, ResolutionCombatant } from '../game/battle-resolution-quote';
import { nextPairedE3NexusStep, quietPairedE3NexusChoice } from './fixture-paired-e3-nexus-modules';
/** Original E3/native faces and PDF21–23 module independence only. No shared-Duke
* training, odd Basic Occupy ruling, exceptional Ghola or Sandtrout override. */
export const MIXED_E3_ECAZ_NEXUS_SOURCES = {
nexus: 'docs/NEXUS_CARD_RULES.md', ecaz: 'docs/NEXUS_ECAZ_RULES.md',
skills: 'docs/LEADER_SKILLS_RULES.md', homeworlds: 'docs/HOMEWORLD_RULES.md',
entry: 'docs/MORITANI_ENTRY_TIMING.md', decisions: 'docs/RULE_DECISIONS.md',
} as const;
export interface MixedE3EcazNexusStep { actor: string; action: Action }
export interface MixedE3EcazNexusTransition { before: Game; step: MixedE3EcazNexusStep; after: Game }
export interface MixedE3EcazNexusOptions {
/** Original undealt lobby or original first-turn setup; supplied entropy is never replaced. */
initial?: Game;
families?: readonly ('ix' | 'choam')[];
roster?: readonly FactionId[];
advanced?: boolean;
/** False selects the original all14 path without a Nexus Cards component. */
nexus?: boolean;
/** Original independent three-card variant; only the no-Nexus all14 path. */
independent?: boolean;
skills?: boolean;
homeworlds?: boolean;
discovery?: boolean;
tech?: boolean;
strongholds?: boolean;
skill?: LeaderSkillId;
/** Original matching Face Dancer lottery, before that first hand is dealt. */
matchingDancer?: boolean;
}
export interface MixedE3EcazNexusSetup {
initial: Game; offered: Game; afterSetup: Game; owner: string;
seats: Partial<Record<FactionId, string>>; trainers: Record<string, string>;
ixOffer: MixedE3EcazNexusTransition | null; actions: MixedE3EcazNexusStep[];
}
export interface MixedE3EcazNexusProgramme extends MixedE3EcazNexusSetup {
game: Game; claim: MixedE3EcazNexusTransition | null;
firstArrival: MixedE3EcazNexusTransition | null; reveal: MixedE3EcazNexusTransition | null;
entry: MixedE3EcazNexusTransition | null; vote: Game | null; votes: MixedE3EcazNexusTransition[];
ride: MixedE3EcazNexusTransition | null; firstMentat: MixedE3EcazNexusTransition | null;
alliance: Game | null; drawing: Game | null; draws: MixedE3EcazNexusTransition[];
}
export interface MixedE3EcazNexusBattle extends MixedE3EcazNexusProgramme {
ally: string; opponent: string; location: string; ecazForces: number;
arrivals: MixedE3EcazNexusTransition[]; beforeBattle: Game; choice: MixedE3EcazNexusStep;
}
export const mixedE3EcazNexusReload = (g: Game): Game => JSON.parse(JSON.stringify(g)) as Game;
export const mixedE3EcazNexusClean = (g: Game): boolean =>
!g.response && !g.phaseOpening && !g.decision && !g.pendingTreacheryDiscard && !g.truthtrance;
export function mixedE3EcazNexusPlayer(g: Game, actor: string): Player {
const p = g.players.find(p => p.id === actor); assert.ok(p); return p;
}
export function mixedE3EcazNexusPolicy(g: Game, actor: string, difficulty: Difficulty): Action[] {
const saved = mixedE3EcazNexusReload(g); mixedE3EcazNexusPlayer(saved, actor).bot = difficulty;
return botActions(viewGame(saved, actor));
}
/** Pure queue controller: owned physical decisions, including posture, are
* returned to the human. This never installs a plan or silently accepts rescue. */
export function nextMixedE3EcazNexusStep(g: Game): MixedE3EcazNexusStep | null {
if (!g.response && !g.phaseOpening && !g.pendingTreacheryDiscard) {
if (g.setupStage === 'leaderSkills') return null;
if (g.decision && ['ecazBattleLead', 'leaderSkillVisibility', 'moritaniPlacement',
'moritaniAssassinate', 'moritaniTerror', 'ecazPlacement', 'ecazAmbassador', 'battleLosses',
'battleCards', 'techToken', 'sukRescue', 'ixSubstitution', 'faceDance', 'strongholdCopy',
'discoveryEntry', 'greatMakerVote', 'greatMakerRide', 'homeworldDefense', 'harassWithdraw',
'homeworldRevivalDeployment', 'moritaniRetention', 'choamBattleFunding'].includes(g.decision.kind)) return null;
if (g.decision?.kind === 'richeseUnbid') return { actor: g.decision.player,
action: { type: 'decision', event: g.richeseBidding!.event, keep: false } };
}
// A revealed battle awaits every voter's declaration; declining is a legal,
// deterministic choice that keeps the physical battle untouched.
const battle = g.battle;
const publicBattle = battle?.revealed ? viewGame(g, g.host).battle : null;
if (!g.response && battle?.revealed && publicBattle) {
const voter = (publicBattle.traitorVoters ?? []).find(id => battle.traitorCalls[id] === undefined);
if (voter) return { actor: voter, action: { type: 'traitorCall', call: false } };
}
const next = nextPairedE3NexusStep(mixedE3EcazNexusReload(g));
return next ? structuredClone(next) : null;
}
export function stepMixedE3EcazNexus(g: Game, step: MixedE3EcazNexusStep,
actions?: MixedE3EcazNexusStep[]): Game {
const after = applyAction(mixedE3EcazNexusReload(g), step.actor, structuredClone(step.action));
actions?.push(structuredClone(step)); return after;
}
export function mixedE3EcazNexusTransition(g: Game, step: MixedE3EcazNexusStep,
actions?: MixedE3EcazNexusStep[]): MixedE3EcazNexusTransition {
return { before: mixedE3EcazNexusReload(g), step: structuredClone(step), after: stepMixedE3EcazNexus(g, step, actions) };
}
export function quietMixedE3EcazNexusChoice(g: Game): MixedE3EcazNexusStep | null {
const d = g.decision;
if (!g.response && !g.phaseOpening && d?.kind === 'choamBattleFunding')
return { actor: d.player, action: { type: 'decision', amount: 0 } };
return quietPairedE3NexusChoice(g);
}
export function advanceMixedE3EcazNexus(g: Game, until: (g: Game) => boolean,
actions?: MixedE3EcazNexusStep[], choose?: (g: Game) => MixedE3EcazNexusStep | null): Game {
for (let n = 0; n < 3600; n++) {
if (until(g)) return g;
assert.notEqual(g.status, 'finished');
const next = nextMixedE3EcazNexusStep(g) ?? choose?.(g);
assert.ok(next, `Owner must choose ${g.decision?.kind ?? 'a sealed plan'} at ${g.turn}/${g.phase}`);
g = stepMixedE3EcazNexus(g, next, actions);
}
throw Error('Original mixed E3 Ecaz chronology did not reach its requested boundary');
}
function permutation<T>(source: readonly T[], desired: readonly T[]): number[] {
assert.equal(source.length, desired.length);
const current = [...source], rolls: number[] = [];
for (let i = current.length - 1; i > 0; i--) {
const j = current.indexOf(desired[i]); assert.ok(j >= 0 && j <= i);
rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1)));
[current[i], current[j]] = [current[j], current[i]];
}
assert.deepEqual(current, desired); return rolls;
}
/** Used only before original component deals. UUID/byte-array entropy stays native. */
function originalLottery<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');
}
}
function preferences(g: Game, requested: LeaderSkillId): LeaderSkillId[] {
const fallback: LeaderSkillId[] = ['swordmaster-of-ginaz', 'prana-bindu-adept', 'killer-medic',
'sandmaster', 'master-of-assassins', 'bureaucrat'];
const result: LeaderSkillId[] = [];
for (const p of g.players) {
const desired = p.faction === 'ecaz' ? requested : fallback.find(s => s !== requested && !result.includes(s));
assert.ok(desired); result.push(desired);
}
return result;
}
/** A conserved original undealt Spice permutation, not a played-phase rewrite.
* Two actual closing Nexuses are arranged for the later coalition chronology. */
function spiceRolls(g: Game): number[] {
const source: SpiceCard[] = [...spiceDeck(g.expansions.includes('ix')),
...(g.discoveryEnabled ? [...DISCOVERY_SPICE_CARDS, { worm: true as const, greatMaker: true as const }] : [])];
const rest = [...source], prefix: SpiceCard[] = [];
const take = (matches: (c: SpiceCard) => boolean) => {
const at = rest.findIndex(matches); assert.ok(at >= 0); prefix.push(rest.splice(at, 1)[0]);
};
take(c => 'territory' in c && (g.discoveryEnabled ? c.discovery === 'discovery-hagga-basin' : !c.discovery && c.territory === 'hagga_basin'));
if (g.advanced) take(c => 'territory' in c && !c.discovery && c.territory === 'broken_land');
take(c => 'worm' in c && (g.discoveryEnabled ? !!c.greatMaker : !c.greatMaker));
take(c => 'territory' in c && !c.discovery && c.territory === 'rock_outcroppings');
if (g.advanced) take(c => 'territory' in c && !c.discovery && c.territory === 'oh_gap');
take(c => 'worm' in c && !c.greatMaker);
take(c => 'territory' in c && !c.discovery && c.territory === 'wind_pass_north');
if (g.advanced) take(c => 'territory' in c && !c.discovery);
return permutation(source, [...prefix, ...rest]);
}
export function createMixedE3EcazNexusSetup(options: MixedE3EcazNexusOptions = {}): MixedE3EcazNexusSetup {
const families = options.families ?? (options.initial
? options.initial.expansions.filter((e): e is 'ix' | 'choam' => e === 'ix' || e === 'choam') : ['ix']);
const roster: readonly FactionId[] = options.roster ?? options.initial?.players.map(p => p.faction) ??
['ecaz', ...(families.includes('ix') ? ['ixians' as const] : []),
...(families.includes('choam') ? ['choam' as const] : []), 'guild', options.discovery ? 'fremen' : 'emperor'];
const nexus = options.nexus ?? true, independent = options.independent ?? false;
const advanced = options.advanced ?? options.initial?.advanced ?? true;
let g = options.initial ? mixedE3EcazNexusReload(options.initial)
: createGame('MIXEDE3ECAZNEXUS', newPlayer(roster[0], roster[0], roster[0]), advanced, ['ecaz', ...families]);
if (!options.initial) for (const f of roster.slice(1)) joinGame(g, newPlayer(f, f, f));
assert.equal(g.advanced, advanced); assert.deepEqual(g.players.map(p => p.faction), roster);
assert.ok(roster.length >= 2 && roster.length <= 6 && new Set(roster).size === roster.length);
assert.ok(roster.includes('ecaz') && families.length >= 1 && families.length <= 2 && new Set(families).size === families.length);
assert.deepEqual([...g.expansions].sort(), ['ecaz', ...families].sort());
if (nexus) for (const family of families)
assert.ok(roster.some(f => (family === 'ix' ? ['ixians', 'tleilaxu'] : ['choam', 'richese']).includes(f)));
assert.equal(!!g.ecazTreachery, options.initial?.status === 'setup' ? independent : false);
assert.ok(g.status === 'lobby' || g.status === 'setup' && g.turn === 1 && g.phase === 0,
'Continue only original lobby/setup, never convert a played save');
const skills = options.skills ?? (options.initial ? !!g.leaderSkills : true);
const flags = { homeworlds: options.homeworlds ?? !!g.homeworlds, techTokens: options.tech ?? !!g.techTokens,
strongholdCards: options.strongholds ?? !!g.strongholdCards };
const discovery = options.discovery ?? !!g.discoveryEnabled;
assert.ok(nexus || skills, 'The no-Nexus route is the original all14 module, not bare mixed Worlds');
assert.ok(!independent || !nexus, 'Independent cards retain their no-Nexus all14 scope');
assert.ok(nexus || !discovery, 'Existing Discovery/Nexus composition stays separate');
assert.ok(!flags.techTokens || roster.length >= 3); assert.ok(!flags.strongholdCards || advanced);
assert.ok(!advanced || !roster.includes('harkonnen') || !skills && !roster.includes('moritani'),
'Original Advanced E3 capture exclusion is not weakened');
const actions: MixedE3EcazNexusStep[] = [], seats: Partial<Record<FactionId, string>> = {};
for (const p of g.players) seats[p.faction] = p.id;
const owner = seats.ecaz!, wanted = preferences(g, options.skill ?? 'warmaster');
if (g.status === 'lobby') {
for (const [type, enabled] of Object.entries(flags)) if (!!g[type as keyof typeof flags] !== enabled)
g = stepMixedE3EcazNexus(g, { actor: g.host, action: { type, enabled } }, actions);
g.discoveryEnabled = discovery;
if (nexus) g.nexusCards ??= { cards: null, phase: null };
else {
assert.equal(g.nexusCards, undefined, 'Never erase a selected or played Nexus component');
if (!independent) g = stepMixedE3EcazNexus(g, { actor: g.host,
action: { type: 'moduleOption', module: 'leaderSkills', enabled: true } }, actions);
}
} else {
for (const [type, enabled] of Object.entries(flags)) assert.equal(!!g[type as keyof typeof flags], enabled);
assert.equal(!!g.discoveryEnabled, discovery); assert.equal(!!g.leaderSkills, skills);
if (nexus) assert.ok(g.nexusCards?.cards, 'An existing deal cannot acquire another Nexus deck');
else assert.equal(g.nexusCards, undefined);
}
const initial = mixedE3EcazNexusReload(g);
if (g.status === 'lobby') {
for (const p of g.players) if (!p.ready) g = stepMixedE3EcazNexus(g, { actor: p.id, action: { type: 'ready' } }, actions);
if (independent) g.ecazTreachery = true;
const skillSource = LEADER_SKILL_CARDS.map(c => c.id), rest = skillSource.filter(c => !wanted.includes(c));
const skillOrder = wanted.flatMap(c => [c, rest.shift()!]).concat(rest);
const deck = [...treacheryDeck(g.expansions), ...(independent ? ecazTreacheryCards() : [])],
remaining = [...deck], front: string[] = [];
for (let n = 0; n < g.players.length + (roster.includes('harkonnen') ? 1 : 0); n++) {
const worthless = remaining.findIndex(c => c.kind === 'worthless');
const at = worthless >= 0 ? worthless : remaining.findIndex(c => c.kind === 'shield');
assert.ok(at >= 0); front.push(remaining.splice(at, 1)[0].id);
}
const rolls = [...(skills ? permutation(skillSource, skillOrder) : []),
...(discovery ? Array<number>(7).fill(0xffffffff) : []),
...(nexus ? permutation(NEXUS_FACTIONS, ['ecaz', ...NEXUS_FACTIONS.filter(f => f !== 'ecaz')]) : []),
...permutation(deck.map(c => c.id), [...front, ...remaining.map(c => c.id)]), ...spiceRolls(g)];
const initialize = () => !nexus && !independent
? stepMixedE3EcazNexus(g, { actor: g.host,
action: advanced ? { type: 'start', advancedPreview: true } : { type: 'start' } }, actions)
: skills ? initializeLeaderSkillsGameForAudit(g) : initializeNexusGameForAudit(g);
g = options.initial ? initialize() : originalLottery(rolls, initialize);
}
const offered = mixedE3EcazNexusReload(g), trainers: Record<string, string> = {};
for (const a of g.leaderSkills?.assignments ?? []) trainers[a.owner] = a.leader;
let ixOffer: MixedE3EcazNexusTransition | null = null;
for (let n = 0; g.status === 'setup' && n < 350; n++) {
let next: MixedE3EcazNexusStep;
if (g.decision?.kind === 'ixSetup') {
const cards = viewGame(g, g.decision.player).ixTechnology!.setup!;
const card = cards.find(c => c.kind === 'worthless') ?? cards[0]; assert.ok(card);
next = { actor: g.decision.player, action: { type: 'decision', card: card.id } };
ixOffer = mixedE3EcazNexusTransition(g, next, actions); g = ixOffer.after; continue;
}
if (g.setupStage === 'leaderSkills') {
const actor = Object.keys(g.leaderSkills!.offers)[0], own = viewGame(g, actor).leaderSkills!;
const skill = options.initial ? own.offer!.cards.find(c => !own.unavailableSkills?.[c])
: wanted[g.players.findIndex(p => p.id === actor)];
const eligible = new Set(own.eligibleLeaders.map(l => l.id));
const leader = mixedE3EcazNexusPlayer(g, actor).leaders.filter(l => eligible.has(l.id))
.sort((a, b) => b.strength - a.strength)[0];
assert.ok(skill && own.offer!.cards.includes(skill) && !own.unavailableSkills?.[skill] && leader);
assert.notEqual(leader.id, DUKE_VIDAL_ID); trainers[actor] = leader.id;
next = { actor, action: { type: 'leaderSkill', event: own.offer!.event, skill, leader: leader.id } };
} else {
const candidate = nextMixedE3EcazNexusStep(g) ?? quietMixedE3EcazNexusChoice(g); assert.ok(candidate); next = candidate;
if (next.action.type === 'traitor') {
const target = trainers[owner] ?? mixedE3EcazNexusPlayer(g, owner).leaders.slice().sort((a, b) => b.strength - a.strength)[0].id;
next.action.leader = mixedE3EcazNexusPlayer(g, next.actor).traitorChoices.find(id => id !== target) ?? next.action.leader;
}
if (next.action.type === 'fremenSetup') next.action.placements = { sietch_tabr: 0, false_wall_south: 10, false_wall_west: 0 };
}
// In this original no-Ixian/no-BG/no-Moritani source profile Ecaz placement
// is the final setup action and deals the first native Face Dancer hand.
if (!options.initial && options.matchingDancer && seats.tleilaxu && !seats.ixians && !seats.beneGesserit && !seats.moritani && next.action.type === 'ecazSetup') {
const target = trainers[owner] ?? mixedE3EcazNexusPlayer(g, owner).leaders.slice().sort((a, b) => b.strength - a.strength)[0].id;
const held = new Set(g.players.flatMap(p => p.traitors));
const source = traitorDeck(g.players, true).filter(id => !held.has(id) && id !== g.ecazLoyalty?.card);
assert.ok(source.includes(target), 'Original Loyalty/traitors must leave this native matching disc available');
g = originalLottery(permutation(source, [target, ...source.filter(id => id !== target)]),
() => stepMixedE3EcazNexus(g, next, actions));
} else g = stepMixedE3EcazNexus(g, next, actions);
}
assert.equal(g.status, 'playing'); mixedE3EcazNexusInventory(g);
return { initial, offered, afterSetup: mixedE3EcazNexusReload(g), owner, seats, trainers, ixOffer, actions };
}
export function mixedE3EcazNexusInventory(g: Game): void {
const nx = g.nexusCards?.cards;
if (nx) {
validateNexusCards(nx, g.players);
assert.deepEqual([...nx.deck, ...nx.discard, ...Object.values(nx.hands).filter(c => c !== null)].sort(), [...NEXUS_FACTIONS].sort());
}
if (g.homeworlds) homeworldGameIntegrity(g);
if (g.discoveries) validateDiscoveryState(g.discoveries);
if (g.leaderSkills) {
validateLeaderSkills(g.leaderSkills, g.players);
assert.ok(g.leaderSkills.assignments.every(a => a.leader !== DUKE_VIDAL_ID));
}
const cards = [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand), ...(g.ixSetupCards ?? []),
...(g.ixAuction?.cards ?? []), ...(g.richeseCache ?? []), ...(g.richeseRemoved ?? []),
...(g.auction?.cards.slice(g.auction.index + (g.currentAuctionSale ? 1 : 0)) ?? [])];
assert.deepEqual(cards.map(c => c.id).sort(), [...treacheryDeck(g.expansions),
...(g.ecazTreachery ? ecazTreacheryCards() : []),
...(g.players.some(p => p.faction === 'richese') ? richeseCards() : [])].map(c => c.id).sort());
const homes = g.homeworlds?.custody ? 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, n) => a + n, 0) +
visitors.reduce((a, f) => a + 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, n) => a + n, 0) +
visitors.reduce((a, f) => a + f.elite, 0), p.faction === 'ixians' ? 7 : p.faction === 'emperor' ? 5 : 3);
assert.ok(p.leaders.every(l => l.id !== DUKE_VIDAL_ID));
}
assert.equal(mixedE3EcazNexusPlayer(g, g.players.find(p => p.faction === 'ecaz')!.id).leaders.length, 5);
const retired = g.moritaniAssassinate?.opportunities.filter(r => r.stage === 'replaced').map(r => r.card!) ?? [];
const traitors = [...g.traitorReserve!, ...g.players.flatMap(p => [...p.traitors, ...p.traitorChoices,
...(p.faceDancers?.map(c => c.leader) ?? [])]), ...retired, ...(g.ecazLoyalty?.card ? [g.ecazLoyalty.card] : [])];
assert.deepEqual(traitors.sort(), traitorDeck(g.players, g.expansions.includes('ix')).sort());
}
export function shipMixedE3EcazNexus(g: Game, actor: string, location: string, amount: number,
actions?: MixedE3EcazNexusStep[], elite = 0): MixedE3EcazNexusTransition {
assert.ok(g.phase === 5 && g.active === actor && mixedE3EcazNexusClean(g));
const sources = g.homeworlds ? nativeShipmentSources(viewGame(g, actor), amount, elite) : undefined;
if (g.homeworlds) assert.ok(sources);
let action: Action;
if (location.startsWith('homeworld:')) {
assert.ok(sources); const quote = homeworldShipmentChoice(viewGame(g, actor), location,
Object.fromEntries(Object.entries(sources).filter(([, f]) => f.normal + f.elite > 0)));
assert.ok(quote.action, quote.blocked ?? undefined); action = quote.action;
} else {
const [territory, sector] = location.split(':');
action = { type: 'ship', territory, sector: Number(sector), amount, elite, allyPayment: 0,
...(sources ? { homeworldSources: sources } : {}) };
}
const t = mixedE3EcazNexusTransition(g, { actor, action }, actions);
t.after = advanceMixedE3EcazNexus(t.after, s => mixedE3EcazNexusClean(s) && !s.pendingShipment &&
!s.pendingHomeworldShipment, actions, quietMixedE3EcazNexusChoice); return t;
}
export function createMixedE3EcazNexusProgramme(options: MixedE3EcazNexusOptions = {}): MixedE3EcazNexusProgramme {
const f = createMixedE3EcazNexusSetup(options), actions = f.actions;
let g = mixedE3EcazNexusReload(f.afterSetup);
const result: MixedE3EcazNexusProgramme = { ...f, game: g, claim: null, firstArrival: null, reveal: null,
entry: null, vote: null, votes: [], ride: null, firstMentat: null, alliance: null, drawing: null, draws: [] };
if (g.players.length === 2) return result;
g = advanceMixedE3EcazNexus(g, s => s.phase === 5 && s.active === f.owner && mixedE3EcazNexusClean(s), actions, quietMixedE3EcazNexusChoice);
const placement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
const destination = g.discoveries ? `${placement.territory}:${placement.sector}` : 'sietch_tabr:14';
const arrival = shipMixedE3EcazNexus(g, f.owner, destination, g.discoveries ? 2 : 1, actions); g = arrival.after;
if (g.discoveries) result.firstArrival = arrival; else result.claim = arrival;
if (g.discoveries) {
g = advanceMixedE3EcazNexus(g, s => s.phase === 7 && mixedE3EcazNexusClean(s), actions, quietMixedE3EcazNexusChoice);
const discoveries = g.discoveries;
assert.ok(discoveries, 'The Discovery module must remain active through the arrival.');
const token = discoveries.tokens.find(t => t.status === 'placed' && t.territory === placement.territory);
assert.ok(token, 'Keep the actual random original supply face, never inject a requested Discovery');
if (viewGame(g, f.owner).discoveries!.canInspect.includes(token.id))
g = stepMixedE3EcazNexus(g, { actor: f.owner, action: { type: 'discovery', token: token.id, reveal: false } }, actions);
result.reveal = mixedE3EcazNexusTransition(g, { actor: f.owner,
action: { type: 'discovery', token: token.id, reveal: true } }, actions); g = result.reveal.after;
}
g = advanceMixedE3EcazNexus(g, s => s.phase === 8 && mixedE3EcazNexusClean(s), actions, quietMixedE3EcazNexusChoice);
for (let n = 0; g.turn === 1 && n < 300; n++) {
const next = nextMixedE3EcazNexusStep(g) ?? quietMixedE3EcazNexusChoice(g); assert.ok(next);
const t = mixedE3EcazNexusTransition(g, next, actions); g = t.after;
if (g.turn === 2) result.firstMentat = t;
}
assert.ok(result.firstMentat);
g = advanceMixedE3EcazNexus(g, s => !!s.nexus && !s.spiceWindow && !s.spiceResolution && mixedE3EcazNexusClean(s), actions, s => {
const d = s.decision;
if (d?.kind === 'discoveryEntry' && d.player === f.owner) {
const view = viewGame(s, f.owner), action = discoveryEntryMoveAction(view, view.discoveryEntry!.sources); assert.ok(action);
// Record the original typed arrival, without choosing a token face.
result.entry = { before: mixedE3EcazNexusReload(s), step: { actor: f.owner, action }, after: s };
return { actor: f.owner, action };
}
if (d?.kind === 'greatMakerVote') {
result.vote ??= mixedE3EcazNexusReload(s);
return { actor: d.player, action: { type: 'decision', event: d.event, yes: true } };
}
if (d?.kind === 'greatMakerRide') {
const action = greatMakerRideAction(viewGame(s, d.player), 'polar_sink', 0, 2, s.advanced ? 1 : 0); assert.ok(action);
result.ride = { before: mixedE3EcazNexusReload(s), step: { actor: d.player, action }, after: s };
return { actor: d.player, action };
}
return quietMixedE3EcazNexusChoice(s);
});
// Replay only the accepted logged transitions from their own pre-action states
// to capture exact entry/vote/ride endpoints, not guessed later snapshots.
if (result.entry) result.entry.after = stepMixedE3EcazNexus(result.entry.before, result.entry.step);
if (result.ride) result.ride.after = stepMixedE3EcazNexus(result.ride.before, result.ride.step);
if (result.vote) {
let state = result.vote;
while (state.decision?.kind === 'greatMakerVote') {
const d = state.decision, t = mixedE3EcazNexusTransition(state, { actor: d.player,
action: { type: 'decision', event: d.event, yes: true } }); result.votes.push(t); state = t.after;
}
}
const others = g.players.filter(p => p.id !== f.owner).map(p => p.id), pair = others.slice(-2);
for (const [actor, target] of [[pair[0], pair[1]], [pair[1], pair[0]]])
g = stepMixedE3EcazNexus(g, { actor, action: { type: 'alliance', target } }, actions);
result.alliance = mixedE3EcazNexusReload(g);
if (g.nexusCards) {
g = advanceMixedE3EcazNexus(g, s => s.nexusCards?.phase?.stage === 'drawing' && mixedE3EcazNexusClean(s), actions, quietMixedE3EcazNexusChoice);
result.drawing = mixedE3EcazNexusReload(g);
assert.ok(g.nexusCards!.phase!.eligible.includes(f.owner));
// The Ecaz face was selected before original Nexus creation, never reordered here.
const draw = mixedE3EcazNexusTransition(g, { actor: f.owner, action: { type: 'nexusCardChoice', turn: g.turn,
card: g.nexusCards!.cards!.hands[f.owner], choice: 'draw', ownRedraws: 0 } }, actions);
result.draws.push(draw); g = draw.after;
}
g = advanceMixedE3EcazNexus(g, s => s.phase === 5 && mixedE3EcazNexusClean(s), actions, quietMixedE3EcazNexusChoice);
mixedE3EcazNexusInventory(g); result.game = g; return result;
}
/** Original coalition chronology; both native and classic allies are supported.
* The Basic odd case stops before chooseBattle so its original guard is exercised. */
export function createMixedE3EcazNexusCoalition(options: MixedE3EcazNexusOptions = {},
allyFaction: FactionId = 'ixians', ecazForces?: number): MixedE3EcazNexusBattle {
const f = createMixedE3EcazNexusProgramme(options); assert.ok(f.claim, 'Coalition programme uses the paid original Tabr claim');
const ally = f.seats[allyFaction], opponent = f.seats.guild ?? f.seats.emperor; assert.ok(ally && opponent && ally !== opponent);
let g = advanceMixedE3EcazNexus(f.game, s => s.turn === 3 && !!s.nexus && !s.spiceWindow &&
!s.spiceResolution && mixedE3EcazNexusClean(s), f.actions, quietMixedE3EcazNexusChoice);
if (mixedE3EcazNexusPlayer(g, ally).ally)
g = stepMixedE3EcazNexus(g, { actor: ally, action: { type: 'alliance', target: null } }, f.actions);
for (const [actor, target] of [[f.owner, ally], [ally, f.owner]])
g = stepMixedE3EcazNexus(g, { actor, action: { type: 'alliance', target } }, f.actions);
const amount = ecazForces ?? (g.advanced ? 5 : 4), location = 'sietch_tabr:14';
const requested: Record<string, number> = { [f.owner]: amount - 1, [ally]: 4, [opponent]: 8 };
const needed = new Set(Object.keys(requested)), arrivals: MixedE3EcazNexusTransition[] = [];
while (needed.size) {
g = advanceMixedE3EcazNexus(g, s => s.phase === 5 && mixedE3EcazNexusClean(s) && needed.has(s.active!), f.actions, quietMixedE3EcazNexusChoice);
const actor = g.active!, t = shipMixedE3EcazNexus(g, actor, location, requested[actor], f.actions);
arrivals.push(t); g = t.after; needed.delete(actor);
if (needed.size) g = stepMixedE3EcazNexus(g, { actor, action: { type: 'endMovement' } }, f.actions);
}
g = advanceMixedE3EcazNexus(g, s => s.phase === 6 && mixedE3EcazNexusClean(s), f.actions, quietMixedE3EcazNexusChoice);
const beforeBattle = mixedE3EcazNexusReload(g), menu = viewGame(g, g.active!).battleChoices.find(c => c.territory === 'sietch_tabr'); assert.ok(menu);
const choice: MixedE3EcazNexusStep = { actor: menu.chooser, action: { type: 'chooseBattle', territory: 'sietch_tabr',
target: menu.attacker === menu.chooser ? menu.defender : menu.attacker } };
if (g.advanced || amount % 2 === 0) g = stepMixedE3EcazNexus(g, choice, f.actions);
return { ...f, game: g, ally, opponent, location, ecazForces: amount, arrivals, beforeBattle, choice };
}
export function quoteMixedE3EcazNexusBattle(g: Game, returns?: HarassWithdrawSelection): BattleResolutionQuote {
const b = g.battle!, publicBattle = viewGame(g, g.host).battle!; assert.ok(b?.revealed);
const side = (actor: string): ResolutionCombatant => {
const p = mixedE3EcazNexusPlayer(g, actor), view = viewGame(g, actor).battle!, plan = b.plans[actor];
assert.ok(view.ownForces);
return { id: actor, faction: p.faction, ally: p.ally, spice: p.spice, hand: p.hand, plan,
leader: p.leaders.find(l => l.id === plan.leader) ??
(g.dukeVidal?.controller === actor && plan.leader === DUKE_VIDAL_ID ? g.dukeVidal.leader : undefined),
forces: view.ownForces, stronghold: view.strongholdEffects[actor],
leaderSkills: g.leaderSkills?.assignments.filter(a => a.owner === actor).map(a => ({ skill: a.skill,
leader: a.leader, faceUp: !b.leaderSkillHidden?.[actor], captured: false })),
...(view.reinforcements && (plan.weapon === 'ecaz-reinforcements' || plan.defense === 'ecaz-reinforcements')
? { reinforcementsReserves: {
normal: p.reserves - (p.elites?.reserves ?? 0), elite: p.elites?.reserves ?? 0 } } : {}),
...(view.harassWithdraw && (plan.weapon === 'ecaz-harass-withdraw' || plan.defense === 'ecaz-harass-withdraw')
? { harassWithdraw: { blocked: null, forces: view.harassWithdraw.forces,
locations: view.harassWithdraw.locations,
...(view.harassWithdraw.occupy ? { occupy: view.harassWithdraw.occupy } : {}) },
...(returns && Object.keys(returns).length ? { harassSelection: returns } : {}) } : {}) };
};
const context = homeworldContext(g), home = b.territory.startsWith('homeworld:')
? homeworldCombatLocation({ ...context, players: context.players.map(p => ({ ...p,
ally: mixedE3EcazNexusPlayer(g, p.id).ally })), order: g.order }, g.homeworlds!.custody!, b.territory) : null;
return quoteBattleResolution({ advanced: g.advanced, typedCasualties: !!g.homeworlds, turn: g.turn,
territory: b.territory, aggressor: publicBattle.aggressor,
...(publicBattle.ecazOccupy?.profile ? { ecazOccupy: publicBattle.ecazOccupy.profile } : {}),
...(home ? { homeworld: { native: home.native, card: home.card, side: home.side, nativeForces: home.forces[home.native] } } : {}),
attacker: side(b.attacker), defender: side(b.defender), participants: g.players,
voters: publicBattle.traitorVoters.map(actor => ({ id: actor, called: b.traitorCalls[actor] ?? false,
traitors: mixedE3EcazNexusPlayer(g, actor).traitors,
beneficiary: [b.attacker, b.defender].includes(actor) ? actor : mixedE3EcazNexusPlayer(g, actor).ally! })),
physicalCards: [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand)],
pendingAuditorPresent: false, pendingRetentionPresent: false, nexusMoritani: !!g.moritaniAssassinate });
}
export function revealMixedE3EcazNexus(g: Game, plans: readonly MixedE3EcazNexusStep[],
actions?: MixedE3EcazNexusStep[]): Game {
for (const plan of plans) {
g = stepMixedE3EcazNexus(g, plan, actions);
if (!g.battle?.revealed) g = advanceMixedE3EcazNexus(g, s => !!s.battle && mixedE3EcazNexusClean(s) &&
!s.battle.preparation && s.battle.preLeader?.closed !== false, actions, quietMixedE3EcazNexusChoice);
}
assert.ok(g.battle?.revealed); return g;
}