734 lines
40 KiB
TypeScript
734 lines
40 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction, captureCandidates, createGame, handLimit, initializeNexusGameForAudit, joinGame,
|
|
newPlayer, viewGame, type Action, type Game, type Player,
|
|
} from '../game/engine';
|
|
import type { FactionId } from '../game/catalog';
|
|
import { leaders, treacheryDeck, type Card, type Leader } from '../game/cards';
|
|
import {
|
|
DUKE_VIDAL_ID, dukeController, type DukeReleasedCaptureWitness, type DukeState,
|
|
} from '../game/duke-vidal';
|
|
import { traitorDeck } from '../game/traitors';
|
|
import { TERRITORIES, territory } from '../game/board';
|
|
import { NEXUS_FACTIONS } from '../game/nexus-cards';
|
|
import { nexusEcazDukeAction } from '../game/nexus-ecaz-duke-options';
|
|
import { validateAmbassadors } from '../game/ecaz-ambassadors';
|
|
import { e3NexusEcazClean, e3NexusEcazReload, nextE3NexusEcazStep, orderE3NexusEcazSpice } from './fixture-e3-nexus-ecaz';
|
|
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
|
|
|
/**
|
|
* Genuine standalone original Advanced Ecaz/Harkonnen Nexus tables (roster
|
|
* Ecaz + Harkonnen + Guild + Fremen, no Skills, no optional modules) for the
|
|
* shared Duke's real capture, liberation and death custody.
|
|
*
|
|
* Every action is issued by a live owner: the paid Ecaz Ambassador placement,
|
|
* the other seat's real arrival, the real Battle declaration and sealed plans,
|
|
* the real capture response, the closing Nexus draw and real card actions.
|
|
* Nothing is staged: no hand, wallet, force, played card, controller, captive
|
|
* marker or clock is assigned. The single scoped exception is the capture
|
|
* lottery, which selects entropy for the ORIGINAL shuffle over this case's
|
|
* actual emitted candidate pool (the engine's own `captureCandidates`).
|
|
*/
|
|
|
|
export type NexusDukeStep = { actor: string; action: Action };
|
|
export type NexusDukeTransition = { before: Game; step: NexusDukeStep; after: Game };
|
|
export type NexusDukeResolution = { before: Game; after: Game };
|
|
export type NexusDukeCaptureDeclaration = { player: string; loser: string; territory: string };
|
|
|
|
/** Exactly the fields this fixture's driver consumes, plus its own progress. */
|
|
export type NexusDukePlan = {
|
|
owner: string;
|
|
captor: string;
|
|
entrant: string;
|
|
others: string[];
|
|
/** The reusable physical Ecaz Ambassador token and where Ecaz really places it. */
|
|
token: string;
|
|
destination: string;
|
|
/** True only until the first placement commits; the token then returns to supply. */
|
|
place: boolean;
|
|
/** Answer to a real capture offer and to the real retained/executed choice. */
|
|
capture: 'accept' | 'decline';
|
|
mode: 'keep' | 'execute';
|
|
/** Exact next real shipments this plan issues, keyed by the acting seat. */
|
|
ship: Record<string, { territory: string; amount: number }>;
|
|
/** One real shipment per seat per turn. */
|
|
shipped: Record<string, string>;
|
|
/** How many real Bidding lots this plan bids one spice for. */
|
|
bid: number;
|
|
/** The turn whose Bidding window this plan already bid in. */
|
|
lastBid: string;
|
|
};
|
|
|
|
/** A driver plan that issues no optional action; used before Ecaz's supply exists. */
|
|
export function emptyNexusDukePlan(): NexusDukePlan {
|
|
return { owner: '', captor: '', entrant: '', others: [], token: '', destination: '',
|
|
place: false, capture: 'accept', mode: 'keep', ship: {}, shipped: {}, bid: 0, lastBid: '' };
|
|
}
|
|
|
|
export const nexusDukeClean = (game: Game): boolean => e3NexusEcazClean(game);
|
|
export const nexusDukeReload = (game: Game): Game => e3NexusEcazReload(game);
|
|
|
|
export function nexusDukePlayer(game: Game, id: string): Player {
|
|
const player = game.players.find((candidate) => candidate.id === id);
|
|
assert.ok(player, `Missing original Nexus Duke seat ${id}`);
|
|
return player;
|
|
}
|
|
export function nexusDukeSeat(game: Game, faction: FactionId): Player {
|
|
const player = game.players.find((candidate) => candidate.faction === faction);
|
|
assert.ok(player, `Missing original ${faction} seat`);
|
|
return player;
|
|
}
|
|
|
|
/** Original undealt Treachery slot selection: which physical card each private
|
|
* setup deal receives. A matcher never invents a card; it only chooses among
|
|
* the undealt originals. */
|
|
export type NexusDukeDeal = (faction: FactionId) => readonly (string | ((card: Card) => boolean))[];
|
|
|
|
const slot = (spec: string | ((card: Card) => boolean) | undefined) => {
|
|
if (typeof spec === 'function') return spec;
|
|
const wanted = spec ?? 'worthless';
|
|
return (card: Card) => card.kind === wanted || card.effect === wanted;
|
|
};
|
|
const isWeapon = (card: Card | undefined): card is Card =>
|
|
!!card && (card.kind === 'projectile' || card.kind === 'poison');
|
|
const defaultDeal: NexusDukeDeal = (faction) =>
|
|
faction === 'harkonnen' ? ['worthless', 'shield'] : ['worthless'];
|
|
const poisonDeal: NexusDukeDeal = (faction) =>
|
|
faction === 'harkonnen' ? ['worthless', 'poison'] : ['worthless'];
|
|
|
|
function permutation<T>(original: readonly T[], desired: readonly T[]): number[] {
|
|
const current = [...original], rolls: number[] = [];
|
|
for (let i = current.length - 1; i > 0; i--) {
|
|
const j = current.indexOf(desired[i]);
|
|
assert.ok(j >= 0 && j <= i, 'The selected permutation must stay inside its original array.');
|
|
rolls.push(Math.floor((j + 0.5) / (i + 1) * 0x100000000));
|
|
[current[i], current[j]] = [current[j], current[i]];
|
|
}
|
|
assert.deepEqual(current, desired);
|
|
return rolls;
|
|
}
|
|
function lottery<T>(rolls: readonly number[], operation: () => T): T {
|
|
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
|
const native = crypto.getRandomValues.bind(crypto);
|
|
let index = 0;
|
|
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
|
if (array instanceof Uint32Array && array.length === 1 && index < rolls.length)
|
|
array[0] = rolls[index++];
|
|
else Reflect.apply(native, crypto, [array]);
|
|
return array;
|
|
};
|
|
try { return operation(); }
|
|
finally {
|
|
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
|
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
|
}
|
|
}
|
|
/** Exact Fisher-Yates entropy that makes the engine's own `shuffle` emit `first`
|
|
* in position zero of the supplied physical candidate array. */
|
|
function shuffleRolls<T extends { id: string }>(source: readonly T[], first: string): number[] {
|
|
const found = source.find((candidate) => candidate.id === first);
|
|
assert.ok(found, 'The shared Duke disc must be in the emitted candidate pool.');
|
|
return permutation(source, [found, ...source.filter((candidate) => candidate.id !== first)]);
|
|
}
|
|
|
|
/** Reorder only the original undealt Spice Cards for the opening land blows. */
|
|
export function primeNexusDukeSpice(game: Game): void {
|
|
orderE3NexusEcazSpice(game);
|
|
}
|
|
|
|
export type NexusDukeSetup = {
|
|
initial: Game; offered: Game; afterSetup: Game; game: Game;
|
|
owner: string; captor: string; entrant: string; others: string[];
|
|
token: string; destination: string;
|
|
actions: NexusDukeStep[];
|
|
};
|
|
export type NexusDukeSetupOptions = {
|
|
roster?: readonly FactionId[];
|
|
deal?: NexusDukeDeal;
|
|
};
|
|
|
|
function dealt(game: Game, deal: NexusDukeDeal): Game {
|
|
const cards = treacheryDeck(['ecaz']), remaining = [...cards], front: string[] = [];
|
|
for (const player of game.players)
|
|
for (let n = 0; n < (player.faction === 'harkonnen' ? 2 : 1); n++) {
|
|
const matcher = slot([...deal(player.faction)][n]);
|
|
const index = remaining.findIndex((card) => matcher(card));
|
|
assert.ok(index >= 0, `The original ${player.faction} deal needs an undealt physical Treachery card.`);
|
|
front.push(remaining.splice(index, 1)[0].id);
|
|
}
|
|
// Auction order keeps the original weapon cards first; every card is conserved.
|
|
const rest = [...remaining.filter(isWeapon), ...remaining.filter((card) => !isWeapon(card))];
|
|
const rolls = [
|
|
...permutation(NEXUS_FACTIONS, ['ecaz', ...NEXUS_FACTIONS.filter((faction) => faction !== 'ecaz')]),
|
|
...permutation(cards.map((card) => card.id), [...front, ...rest.map((card) => card.id)]),
|
|
];
|
|
return lottery(rolls, () => initializeNexusGameForAudit(game));
|
|
}
|
|
|
|
/** Genuine completed standalone original setup. Stable seam for other suites. */
|
|
export function createNexusDukeCaptureSetup(options: NexusDukeSetupOptions = {}): NexusDukeSetup {
|
|
const roster = options.roster ?? (['ecaz', 'harkonnen', 'guild', 'fremen'] as FactionId[]);
|
|
let game = createGame('NEXUSDUKECAPTURE', newPlayer(roster[0], roster[0], roster[0]), true, ['ecaz']);
|
|
for (const faction of roster.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
|
|
assert.equal(game.status, 'lobby');
|
|
assert.equal(game.advanced, true);
|
|
assert.deepEqual(game.expansions, ['ecaz']);
|
|
assert.ok(!game.leaderSkills && !game.ecazTreachery && !game.homeworlds);
|
|
assert.ok(roster.includes('ecaz') && roster.includes('harkonnen') && roster.length >= 3);
|
|
const actions: NexusDukeStep[] = [], blank = emptyNexusDukePlan();
|
|
const host = game.host;
|
|
for (const [type, enabled] of [['techTokens', false], ['strongholdCards', false], ['homeworlds', false]] as const)
|
|
if (!!game[type] !== enabled)
|
|
game = stepNexusDuke(game, blank, { actor: host, action: { type, enabled } }, actions);
|
|
game.discoveryEnabled = false;
|
|
game.nexusCards ??= { cards: null, phase: null };
|
|
const initial = nexusDukeReload(game);
|
|
for (const player of game.players)
|
|
if (!player.ready)
|
|
game = stepNexusDuke(game, blank, { actor: player.id, action: { type: 'ready' } }, actions);
|
|
game = dealt(game, options.deal ?? defaultDeal);
|
|
const offered = nexusDukeReload(game);
|
|
for (let n = 0; game.status === 'setup' && n < 240; n++) {
|
|
const next = nextE3NexusEcazStep(game);
|
|
assert.ok(next, 'The original setup needs its actual current owner.');
|
|
game = stepNexusDuke(game, blank, next, actions);
|
|
}
|
|
assert.equal(game.status, 'playing');
|
|
const owner = nexusDukeSeat(game, 'ecaz').id, captor = nexusDukeSeat(game, 'harkonnen').id;
|
|
const others = game.players.filter((player) => player.id !== owner).map((player) => player.id);
|
|
const guild = game.players.find((player) => player.faction === 'guild');
|
|
const entrant = (guild && guild.id !== owner ? guild.id : others.find((id) => id !== owner))!;
|
|
assert.notEqual(entrant, owner);
|
|
return { initial, offered, afterSetup: nexusDukeReload(game), game: nexusDukeReload(game),
|
|
owner, captor, entrant, others, token: reusableToken(game), destination: ambientStronghold(game), actions };
|
|
}
|
|
function reusableToken(game: Game): string {
|
|
const token = game.ecazAmbassadors?.tokens.find((candidate) => candidate.effect === 'ecaz');
|
|
assert.ok(token, 'The reusable Ecaz Ambassador token must be in the original supply.');
|
|
return token.id;
|
|
}
|
|
/** An empty, storm-free stronghold that no faction occupies yet. */
|
|
function ambientStronghold(game: Game): string {
|
|
const occupied = new Set<string>();
|
|
for (const player of game.players)
|
|
for (const key of Object.keys(player.forces)) occupied.add(key.split(':')[0]);
|
|
const found = TERRITORIES.find((candidate) => candidate.type === 'stronghold' &&
|
|
!candidate.sectors.includes(game.storm) && !occupied.has(candidate.id));
|
|
assert.ok(found, 'The original strongholds must leave one empty placement stronghold.');
|
|
return found.id;
|
|
}
|
|
function shipmentSector(game: Game, id: string): number {
|
|
const sectors = territory(id).sectors;
|
|
return sectors.find((sector) => sector !== game.storm) ?? sectors[0];
|
|
}
|
|
|
|
/** A plan for a settled table: no optional action unless the caller asks. */
|
|
export function quietPlan(game: Game, overrides: Partial<NexusDukePlan> = {}): NexusDukePlan {
|
|
const owner = nexusDukeSeat(game, 'ecaz').id;
|
|
const captor = game.players.find((player) => player.faction === 'harkonnen')?.id ??
|
|
game.players.find((player) => player.id !== owner)!.id;
|
|
const others = game.players.filter((player) => player.id !== owner).map((player) => player.id);
|
|
return {
|
|
owner, captor, entrant: others.find((id) => id !== captor)!, others,
|
|
token: reusableToken(game), destination: ambientStronghold(game),
|
|
place: false, capture: 'accept', mode: 'keep', ship: {}, shipped: {}, bid: 0, lastBid: '',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
/** The exact next action of the original chronology. A null result means a real
|
|
* owner must still make a private choice (or seal a private Battle Plan). */
|
|
export function nextNexusDukeStep(game: Game, plan: NexusDukePlan): NexusDukeStep | null {
|
|
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
|
|
const decision = game.decision;
|
|
if (decision?.kind === 'captureOffer')
|
|
return { actor: decision.player, action: { type: 'decision', accept: plan.capture === 'accept' } };
|
|
if (decision?.kind === 'capturedLeader')
|
|
return { actor: decision.player, action: { type: 'decision', mode: plan.mode } };
|
|
if (decision?.kind === 'battleLosses')
|
|
return { actor: decision.player, action: { type: 'decision', choice: 0 } };
|
|
if (decision?.kind === 'battleCards')
|
|
return { actor: decision.player, action: { type: 'decision', discard: [] } };
|
|
if (decision?.kind === 'techToken')
|
|
return { actor: decision.player, action: { type: 'decision', token: decision.choices[0] } };
|
|
if (decision?.kind === 'ecazPlacement') {
|
|
const token = game.ecazAmbassadors?.tokens.find((candidate) => candidate.id === plan.token);
|
|
return plan.place && token?.zone === 'supply'
|
|
? { actor: decision.player, action: { type: 'decision', token: plan.token, territory: plan.destination } }
|
|
: { actor: decision.player, action: { type: 'decision', decline: true } };
|
|
}
|
|
if (decision?.kind === 'ecazAmbassador') {
|
|
const entry = game.pendingAmbassador;
|
|
if (entry?.token === plan.token && entry.stage === 'offer' && entry.owner === plan.owner)
|
|
return { actor: decision.player, action: { type: 'decision', event: entry.event,
|
|
trigger: true, beneficiary: plan.owner, choice: 'duke' } };
|
|
}
|
|
if (game.phase === 3 && game.auction && plan.bid > 0 && game.auction.active === plan.owner &&
|
|
plan.lastBid !== String(game.turn) && !game.auction.bidder &&
|
|
isWeapon(game.auction.cards[game.auction.index]) &&
|
|
nexusDukePlayer(game, plan.owner).hand.length < handLimit(nexusDukePlayer(game, plan.owner))) {
|
|
plan.lastBid = String(game.turn);
|
|
return { actor: plan.owner, action: { type: 'bid', amount: 1 } };
|
|
}
|
|
const order = game.phase === 5 && game.active ? plan.ship[game.active] : undefined;
|
|
if (order && nexusDukeClean(game) && plan.shipped[game.active!] !== String(game.turn))
|
|
return { actor: game.active!, action: { type: 'ship', territory: order.territory,
|
|
sector: shipmentSector(game, order.territory), amount: order.amount, elite: 0, allyPayment: 0 } };
|
|
}
|
|
return nextE3NexusEcazStep(game);
|
|
}
|
|
|
|
export function stepNexusDuke(game: Game, plan: NexusDukePlan, step: NexusDukeStep,
|
|
actions?: NexusDukeStep[]): Game {
|
|
actions?.push(structuredClone(step));
|
|
if (step.action.type === 'ship') plan.shipped[step.actor] = String(game.turn);
|
|
const state = nexusDukeReload(game);
|
|
if (state.pendingCapture && (state.response?.kind === 'capture' ||
|
|
state.decision?.kind === 'captureOffer' && step.action.type === 'decision' && step.action.accept === true)) {
|
|
const rolls = shuffleRolls(emittedCandidates(state), DUKE_VIDAL_ID);
|
|
return lottery(rolls, () => applyAction(state, step.actor, step.action));
|
|
}
|
|
return withClassicDiscoveryNexusLottery(() => applyAction(state, step.actor, step.action));
|
|
}
|
|
|
|
/** The case's own candidate pool, read from the engine's original admission.
|
|
* The released-battle witness the capture itself published is validated here. */
|
|
function emittedCandidates(game: Game): Leader[] {
|
|
const pending = game.pendingCapture;
|
|
assert.ok(pending, 'The original capture declaration must still be current.');
|
|
return captureCandidates(game, pending.loser, pending.territory, releasedWitness(game));
|
|
}
|
|
function releasedWitness(game: Game): DukeReleasedCaptureWitness | undefined {
|
|
const pending = game.pendingCapture;
|
|
if (!pending || !('duke' in pending)) return undefined;
|
|
const candidate: unknown = pending.duke;
|
|
if (candidate === undefined) return undefined;
|
|
if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate))
|
|
throw new Error('The original capture lost its physical released-battle witness.');
|
|
const deaths = 'deaths' in candidate ? candidate.deaths : undefined;
|
|
const usedAt = 'usedAt' in candidate ? candidate.usedAt : undefined;
|
|
if (typeof deaths !== 'number' || !Number.isSafeInteger(deaths) ||
|
|
typeof usedAt !== 'string' || !usedAt)
|
|
throw new Error('The original capture lost its physical released-battle witness.');
|
|
return { deaths, usedAt };
|
|
}
|
|
|
|
export function advanceNexusDuke(state: Game, until: (game: Game) => boolean, plan: NexusDukePlan,
|
|
actions?: NexusDukeStep[]): Game {
|
|
let game = state;
|
|
for (let n = 0; n < 2600; n++) {
|
|
if (until(game)) return game;
|
|
assert.equal(game.status, 'playing');
|
|
const next = nextNexusDukeStep(game, plan);
|
|
assert.ok(next, `The original chronology needs its real owner at ${game.turn}/${game.phase}/${game.decision?.kind ?? 'open'}.`);
|
|
game = stepNexusDuke(game, plan, next, actions);
|
|
}
|
|
throw Error('The original Nexus Duke chronology did not reach its requested window.');
|
|
}
|
|
|
|
function living(game: Game, id: string): Leader[] {
|
|
return nexusDukePlayer(game, id).leaders.filter((leader) => !leader.dead);
|
|
}
|
|
function strongest(list: readonly Leader[]): Leader {
|
|
const sorted = [...list].sort((a, b) => b.strength - a.strength)[0];
|
|
assert.ok(sorted, 'The original roster must still own a living leader.');
|
|
return sorted;
|
|
}
|
|
function weakest(list: readonly Leader[]): Leader {
|
|
const sorted = [...list].sort((a, b) => a.strength - b.strength)[0];
|
|
assert.ok(sorted, 'The original roster must still own a living leader.');
|
|
return sorted;
|
|
}
|
|
|
|
/** The real paid Ecaz Ambassador placement, then another seat's real arrival. */
|
|
function ambassadorAcquisition(state: Game, plan: NexusDukePlan, actions: NexusDukeStep[]): {
|
|
placement: NexusDukeTransition; arrival: NexusDukeTransition & { duke: DukeState }; game: Game;
|
|
} {
|
|
let game = advanceNexusDuke(state, (candidate) => candidate.decision?.kind === 'ecazPlacement', plan, actions);
|
|
const placementBefore = nexusDukeReload(game);
|
|
const placementStep: NexusDukeStep = { actor: plan.owner, action: {
|
|
type: 'decision', token: plan.token, territory: plan.destination } };
|
|
game = stepNexusDuke(game, plan, placementStep, actions);
|
|
game = advanceNexusDuke(game, (candidate) =>
|
|
candidate.ecazAmbassadors!.tokens.find((token) => token.id === plan.token)!.zone === 'placed', plan, actions);
|
|
const placement: NexusDukeTransition = { before: placementBefore, step: placementStep, after: nexusDukeReload(game) };
|
|
plan.place = false;
|
|
plan.ship = {
|
|
[plan.entrant]: { territory: plan.destination, amount: 1 },
|
|
[plan.owner]: { territory: 'carthag', amount: 1 },
|
|
};
|
|
game = advanceNexusDuke(game, (candidate) => candidate.decision?.kind === 'ecazAmbassador' &&
|
|
candidate.pendingAmbassador?.token === plan.token &&
|
|
candidate.pendingAmbassador.stage === 'offer', plan, actions);
|
|
const arrivalBefore = nexusDukeReload(game);
|
|
const arrivalStep: NexusDukeStep = { actor: plan.owner, action: { type: 'decision',
|
|
event: game.pendingAmbassador!.event, trigger: true, beneficiary: plan.owner, choice: 'duke' } };
|
|
game = stepNexusDuke(game, plan, arrivalStep, actions);
|
|
assert.equal(game.dukeVidal?.controller, plan.owner, 'The real Ecaz Ambassador must acquire the shared disc.');
|
|
assert.equal(game.dukeVidal?.source, 'ecaz');
|
|
const arrival = { before: arrivalBefore, step: arrivalStep, after: nexusDukeReload(game),
|
|
duke: structuredClone(game.dukeVidal!) };
|
|
plan.ship = {};
|
|
return { placement, arrival, game };
|
|
}
|
|
|
|
/** Real Battle declaration; both private Battle Plans still need their owners. */
|
|
export function openNexusDukeBattle(state: Game, plan: NexusDukePlan, actions: NexusDukeStep[],
|
|
where: { territory: string; first: string; second: string }): NexusDukeTransition & { game: Game } {
|
|
let game = advanceNexusDuke(state, (candidate) =>
|
|
candidate.phase === 6 && nexusDukeClean(candidate) && !!candidate.active, plan, actions);
|
|
const choice = viewGame(game, game.active!).battleChoices.find((candidate) =>
|
|
candidate.territory === where.territory &&
|
|
[where.first, where.second].includes(candidate.attacker) &&
|
|
[where.first, where.second].includes(candidate.defender));
|
|
assert.ok(choice, `The real ${where.territory} Battle between the original seats must be offered.`);
|
|
const step: NexusDukeStep = { actor: choice.chooser, action: { type: 'chooseBattle',
|
|
territory: where.territory,
|
|
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
|
|
const before = nexusDukeReload(game);
|
|
game = stepNexusDuke(game, plan, step, actions);
|
|
game = advanceNexusDuke(game, (candidate) => !!candidate.battle && nexusDukeClean(candidate) &&
|
|
!candidate.battle.preparation && candidate.battle.preLeader?.closed !== false, plan, actions);
|
|
return { before, step, after: nexusDukeReload(game), game };
|
|
}
|
|
export type NexusDukeSeatPlan = {
|
|
actor: string; leader: string | null; dial: number; support?: number;
|
|
weapon?: string; defense?: string;
|
|
};
|
|
export function sealNexusDukeBattle(state: Game, plan: NexusDukePlan, actions: NexusDukeStep[],
|
|
plans: readonly NexusDukeSeatPlan[]): Game {
|
|
let game = state;
|
|
for (const entry of plans) {
|
|
const step: NexusDukeStep = { actor: entry.actor, action: { type: 'battlePlan',
|
|
leader: entry.leader, dial: entry.dial, support: entry.support ?? 0,
|
|
...(entry.weapon ? { weapon: entry.weapon } : {}),
|
|
...(entry.defense ? { defense: entry.defense } : {}) } };
|
|
game = stepNexusDuke(game, plan, step, actions);
|
|
if (!game.battle?.revealed)
|
|
game = advanceNexusDuke(game, (candidate) => !!candidate.battle && nexusDukeClean(candidate) &&
|
|
!candidate.battle.preparation && candidate.battle.preLeader?.closed !== false, plan, actions);
|
|
}
|
|
assert.ok(game.battle?.revealed, 'The original sealed plans must reveal this Battle.');
|
|
return game;
|
|
}
|
|
|
|
export type NexusDukeClosing = {
|
|
alliance: Game; before: Game; step: NexusDukeStep; after: Game; game: Game;
|
|
};
|
|
/** The genuine closing Nexus: two other seats pact, the real Spice Blow/Nexus
|
|
* runs, and unallied Ecaz spends that phase's real Nexus choice on its own card. */
|
|
export function closeNexusDukeDraw(state: Game, plan: NexusDukePlan, actions: NexusDukeStep[]): NexusDukeClosing {
|
|
assert.ok(plan.others.length >= 2, 'The original pact needs two other real seats.');
|
|
let game = advanceNexusDuke(state, (candidate) =>
|
|
candidate.turn === 2 && candidate.phase === 1 && nexusDukeClean(candidate), plan, actions);
|
|
orderE3NexusEcazSpice(game, true);
|
|
game = advanceNexusDuke(game, (candidate) =>
|
|
candidate.nexus && !candidate.spiceWindow && !candidate.spiceResolution && nexusDukeClean(candidate), plan, actions);
|
|
for (const [actor, target] of [[plan.others[0], plan.others[1]], [plan.others[1], plan.others[0]]])
|
|
game = stepNexusDuke(game, plan, { actor, action: { type: 'alliance', target } }, actions);
|
|
const alliance = nexusDukeReload(game);
|
|
assert.equal(nexusDukePlayer(game, plan.owner).ally, null, 'Ecaz must stay unallied for the closing draw.');
|
|
game = advanceNexusDuke(game, (candidate) =>
|
|
candidate.nexusCards?.phase?.stage === 'drawing' && nexusDukeClean(candidate), plan, actions);
|
|
assert.ok(game.nexusCards!.phase!.eligible.includes(plan.owner));
|
|
const before = nexusDukeReload(game);
|
|
const step: NexusDukeStep = { actor: plan.owner, action: { type: 'nexusCardChoice',
|
|
turn: game.turn, card: game.nexusCards!.cards!.hands[plan.owner], choice: 'draw', ownRedraws: 0 } };
|
|
game = stepNexusDuke(game, plan, step, actions);
|
|
game = advanceNexusDuke(game, (candidate) => candidate.nexusCards?.phase?.stage === 'complete', plan, actions);
|
|
assert.equal(game.nexusCards!.cards!.hands[plan.owner], 'ecaz',
|
|
'The real closing draw must leave Ecaz holding the physical Ecaz Nexus card.');
|
|
return { alliance, before, step, after: nexusDukeReload(game), game };
|
|
}
|
|
|
|
export type NexusDukeWindow = { game: Game; action: Action; event: string; plan: NexusDukePlan };
|
|
/** The first quiet Battle boundary where native Ecaz physically holds its own
|
|
* Nexus card and stays unallied. Real owners keep every other choice. */
|
|
export function quietNexusDukeWindow(state: Game, options: Partial<NexusDukePlan> = {},
|
|
actions: NexusDukeStep[] = []): NexusDukeWindow {
|
|
const plan = quietPlan(state, options);
|
|
// An empty Battle phase closes automatically. Keep a real pending battle
|
|
// through a legal native shipment rather than installing an artificial pause.
|
|
if (!Object.keys(plan.ship).length)
|
|
plan.ship = { [plan.owner]: { territory: 'carthag', amount: 1 } };
|
|
let game = state;
|
|
if (game.nexusCards?.cards?.hands[plan.owner] !== 'ecaz')
|
|
game = closeNexusDukeDraw(game, plan, actions).game;
|
|
game = advanceNexusDuke(game, (candidate) => candidate.phase === 6 && nexusDukeClean(candidate) &&
|
|
!candidate.battle && candidate.nexusCards?.cards?.hands[plan.owner] === 'ecaz', plan, actions);
|
|
const action = nexusEcazDukeAction(viewGame(game, plan.owner));
|
|
assert.ok(action, 'The quiet Ecaz Nexus Cunning window must be available.');
|
|
assert.ok(typeof action.event === 'string');
|
|
return { game, action, event: action.event, plan };
|
|
}
|
|
|
|
export type NexusDukeCaptureVariant = 'unused' | 'used';
|
|
export type NexusDukeCaptureOptions = {
|
|
variant?: NexusDukeCaptureVariant;
|
|
mode?: 'keep' | 'execute';
|
|
roster?: readonly FactionId[];
|
|
deal?: NexusDukeDeal;
|
|
};
|
|
|
|
export type NexusDukeCaptureFixture = {
|
|
initial: Game; offered: Game; afterSetup: Game;
|
|
owner: string; captor: string; entrant: string; others: string[];
|
|
token: string; destination: string;
|
|
placement: NexusDukeTransition;
|
|
arrival: NexusDukeTransition & { duke: DukeState };
|
|
battle: NexusDukeTransition & { territory: string };
|
|
event: string;
|
|
sealed: Game;
|
|
declared: NexusDukeCaptureDeclaration | null;
|
|
offer: NexusDukeTransition | null;
|
|
candidates: Leader[];
|
|
resolved: NexusDukeResolution | null;
|
|
decision: NexusDukeTransition | null;
|
|
duke: DukeState;
|
|
plan: NexusDukePlan;
|
|
game: Game;
|
|
actions: NexusDukeStep[];
|
|
};
|
|
|
|
type InternalCaptureOptions = NexusDukeCaptureOptions & {
|
|
leadDuke: boolean; poison: boolean; capture: 'accept' | 'decline';
|
|
};
|
|
|
|
function captureChronology(options: InternalCaptureOptions): NexusDukeCaptureFixture {
|
|
const setup = createNexusDukeCaptureSetup({ roster: options.roster,
|
|
deal: options.deal ?? (options.poison ? poisonDeal : defaultDeal) });
|
|
const actions = setup.actions;
|
|
const plan: NexusDukePlan = {
|
|
...quietPlan(setup.afterSetup), place: true,
|
|
capture: options.capture, mode: options.mode ?? 'keep',
|
|
};
|
|
primeNexusDukeSpice(setup.afterSetup);
|
|
let game = nexusDukeReload(setup.afterSetup);
|
|
const acquisition = ambassadorAcquisition(game, plan, actions);
|
|
game = acquisition.game;
|
|
plan.ship = { [plan.owner]: { territory: 'carthag', amount: 1 } };
|
|
game = advanceNexusDuke(game, (candidate) =>
|
|
candidate.phase === 6 && nexusDukeClean(candidate) && !!candidate.active, plan, actions);
|
|
plan.ship = {};
|
|
const battle = openNexusDukeBattle(game, plan, actions,
|
|
{ territory: 'carthag', first: plan.owner, second: plan.captor });
|
|
game = battle.game;
|
|
const weapon = options.poison
|
|
? nexusDukePlayer(game, plan.captor).hand.find((card) => card.kind === 'poison')
|
|
: undefined;
|
|
if (options.poison) assert.ok(weapon, 'The real setup deal must leave Harkonnen holding the physical poison.');
|
|
game = sealNexusDukeBattle(game, plan, actions, [
|
|
{ actor: plan.owner, leader: options.leadDuke ? DUKE_VIDAL_ID : weakest(living(game, plan.owner)).id,
|
|
dial: 0, support: 0 },
|
|
{ actor: plan.captor, leader: strongest(living(game, plan.captor)).id, dial: 2, support: 0,
|
|
...(weapon ? { weapon: weapon.id } : {}) },
|
|
]);
|
|
const sealed = nexusDukeReload(game);
|
|
const battleEvent = sealed.battle?.event ?? '';
|
|
game = advanceNexusDuke(game, (candidate) =>
|
|
candidate.decision?.kind === 'captureOffer' || (!candidate.battle && nexusDukeClean(candidate)), plan, actions);
|
|
let declared: NexusDukeCaptureDeclaration | null = null, offer: NexusDukeTransition | null = null;
|
|
let resolved: NexusDukeResolution | null = null, decision: NexusDukeTransition | null = null;
|
|
let candidates: Leader[] = [];
|
|
if (game.decision?.kind === 'captureOffer' && options.capture === 'decline') {
|
|
game = stepNexusDuke(game, plan,
|
|
{ actor: game.decision.player, action: { type: 'decision', accept: false } }, actions);
|
|
} else if (game.decision?.kind === 'captureOffer') {
|
|
const pending = game.pendingCapture!;
|
|
declared = { player: pending.player, loser: pending.loser, territory: pending.territory };
|
|
candidates = emittedCandidates(game);
|
|
const acceptStep: NexusDukeStep = { actor: game.decision.player, action: { type: 'decision', accept: true } };
|
|
const offerBefore = nexusDukeReload(game);
|
|
game = stepNexusDuke(game, plan, acceptStep, actions);
|
|
offer = { before: offerBefore, step: acceptStep, after: nexusDukeReload(game) };
|
|
const resolveBefore = nexusDukeReload(game);
|
|
game = advanceNexusDuke(game, (candidate) =>
|
|
candidate.decision?.kind === 'capturedLeader' || (!candidate.battle && nexusDukeClean(candidate)), plan, actions);
|
|
assert.ok(game.decision?.kind === 'capturedLeader', 'The original capture response must select the shared disc.');
|
|
assert.equal(game.decision.leader, DUKE_VIDAL_ID);
|
|
resolved = { before: resolveBefore, after: nexusDukeReload(game) };
|
|
const modeStep: NexusDukeStep = { actor: game.decision!.player, action: { type: 'decision', mode: plan.mode } };
|
|
const modeBefore = nexusDukeReload(game);
|
|
game = stepNexusDuke(game, plan, modeStep, actions);
|
|
decision = { before: modeBefore, step: modeStep, after: nexusDukeReload(game) };
|
|
}
|
|
game = advanceNexusDuke(game, (candidate) =>
|
|
!candidate.battle && !candidate.pendingCapture && nexusDukeClean(candidate), plan, actions);
|
|
return {
|
|
initial: setup.initial, offered: setup.offered, afterSetup: setup.afterSetup,
|
|
owner: plan.owner, captor: plan.captor, entrant: plan.entrant, others: plan.others,
|
|
token: plan.token, destination: plan.destination,
|
|
placement: acquisition.placement, arrival: acquisition.arrival,
|
|
battle: { ...battle, territory: 'carthag' }, event: battleEvent, sealed,
|
|
declared, offer, candidates, resolved, decision,
|
|
duke: structuredClone(game.dukeVidal!), plan, game: nexusDukeReload(game), actions,
|
|
};
|
|
}
|
|
|
|
/** Real Harkonnen capture of the shared disc: unused, or just used in the very
|
|
* Battle that captures it. */
|
|
export function createNexusDukeCaptureFixture(options: NexusDukeCaptureOptions = {}): NexusDukeCaptureFixture {
|
|
const variant = options.variant ?? 'unused';
|
|
return captureChronology({ ...options, leadDuke: variant === 'used', poison: false, capture: 'accept' });
|
|
}
|
|
|
|
export type NexusDukeDeathFixture = {
|
|
fixture: NexusDukeCaptureFixture;
|
|
/** Settled turn-1 aftermath with the real death applied. */
|
|
game: Game;
|
|
/** The real closing draw, then the quiet Battle boundary where Ecaz holds its card. */
|
|
window: Game;
|
|
action: Action;
|
|
owner: string; captor: string; entrant: string;
|
|
deaths: number; duke: DukeState; plan: NexusDukePlan; actions: NexusDukeStep[];
|
|
};
|
|
|
|
/** Real Battle death of the shared disc: Ecaz commits the Duke, loses, and the
|
|
* winning Harkonnen plan's real poison card kills it into the Tanks. */
|
|
export function createNexusDukeDeathFixture(options: NexusDukeCaptureOptions = {}): NexusDukeDeathFixture {
|
|
const fixture = captureChronology({ ...options, leadDuke: true, poison: true, capture: 'decline' });
|
|
assert.equal(fixture.game.dukeVidal!.leader.dead, true, 'The real poison must kill the shared disc.');
|
|
const quiet = quietNexusDukeWindow(fixture.game, fixture.plan, fixture.actions);
|
|
return { fixture, game: fixture.game, window: quiet.game, action: quiet.action,
|
|
owner: fixture.owner, captor: fixture.captor, entrant: fixture.entrant,
|
|
deaths: fixture.game.dukeVidal!.leader.deaths, duke: structuredClone(fixture.game.dukeVidal!),
|
|
plan: fixture.plan, actions: fixture.actions };
|
|
}
|
|
|
|
export type NexusDukeReclaim = {
|
|
closing: NexusDukeClosing; window: Game; before: Game; step: NexusDukeStep; after: Game; duke: DukeState;
|
|
};
|
|
/** Genuine Ecaz Nexus Cunning liberation of the actual captured shared disc,
|
|
* reached through the real closing draw. */
|
|
export function reclaimCapturedNexusDuke(state: Game, plan: NexusDukePlan,
|
|
actions: NexusDukeStep[] = [], options: { battle?: boolean } = {}): NexusDukeReclaim {
|
|
assert.ok(state.dukeVidal?.leader.capturedBy, 'Ecaz Nexus Cunning must liberate a real captive.');
|
|
assert.equal(dukeController(state.dukeVidal!), plan.captor);
|
|
const closing = closeNexusDukeDraw(state, plan, actions);
|
|
if (options.battle) plan.ship = { [plan.owner]: { territory: 'carthag', amount: 1 } };
|
|
const quiet = quietNexusDukeWindow(closing.game, plan, actions);
|
|
plan.ship = {};
|
|
plan.place = false;
|
|
const step: NexusDukeStep = { actor: plan.owner, action: quiet.action };
|
|
const before = nexusDukeReload(quiet.game);
|
|
const game = stepNexusDuke(quiet.game, plan, step, actions);
|
|
assert.equal(dukeController(game.dukeVidal!), plan.owner);
|
|
assert.equal(game.dukeVidal!.leader.capturedBy, undefined);
|
|
return { closing, window: quiet.game, before, step, after: nexusDukeReload(game),
|
|
duke: structuredClone(game.dukeVidal!) };
|
|
}
|
|
|
|
export type NexusDukeUseRelease = {
|
|
closing: Game; battle: NexusDukeTransition; game: Game; duke: DukeState;
|
|
};
|
|
/** The captor really uses the retained captive in one later Battle; the original
|
|
* aftermath then releases and sets the same disc aside. */
|
|
export function releaseCapturedNexusDukeByUse(state: Game, plan: NexusDukePlan,
|
|
options: { territory?: string; captorForces?: number } = {},
|
|
actions: NexusDukeStep[] = []): NexusDukeUseRelease {
|
|
const territoryId = options.territory ?? 'imperial_basin', captorForces = options.captorForces ?? 5;
|
|
assert.equal(dukeController(state.dukeVidal!), plan.captor);
|
|
plan.capture = 'decline';
|
|
const closing = closeNexusDukeDraw(state, plan, actions).game;
|
|
plan.ship = { [plan.captor]: { territory: territoryId, amount: captorForces } };
|
|
let game = advanceNexusDuke(closing, (candidate) =>
|
|
candidate.phase === 6 && nexusDukeClean(candidate) && !!candidate.active, plan, actions);
|
|
plan.ship = {};
|
|
const battle = openNexusDukeBattle(game, plan, actions,
|
|
{ territory: territoryId, first: plan.captor, second: plan.owner });
|
|
game = sealNexusDukeBattle(battle.game, plan, actions, [
|
|
{ actor: plan.captor, leader: DUKE_VIDAL_ID, dial: 0, support: 0 },
|
|
{ actor: plan.owner, leader: weakest(living(game, plan.owner)).id, dial: 0, support: 0 },
|
|
]);
|
|
game = advanceNexusDuke(game, (candidate) =>
|
|
!candidate.battle && !candidate.decision && nexusDukeClean(candidate), plan, actions);
|
|
assert.equal(game.dukeVidal!.leader.capturedBy, undefined);
|
|
return { closing, battle, game, duke: structuredClone(game.dukeVidal!) };
|
|
}
|
|
|
|
export type NexusDukeLeaderDeath = { leader: string; weapon: string; turn: number };
|
|
export type NexusDukeReturn = {
|
|
game: Game; deaths: NexusDukeLeaderDeath[]; returned: Game; duke: DukeState;
|
|
};
|
|
function carthagForces(game: Game, id: string): number {
|
|
return Object.entries(nexusDukePlayer(game, id).forces)
|
|
.filter(([key]) => key.startsWith('carthag:'))
|
|
.reduce((sum, [, count]) => sum + count, 0);
|
|
}
|
|
/** Real route to the all-native-Harkonnen-dead return: every living Harkonnen
|
|
* leader dies in a real Battle Ecaz wins with a real auctioned weapon, and the
|
|
* aftermath that kills the last one returns the captive shared disc. */
|
|
export function releaseCapturedNexusDukeByAllHarkDead(state: Game, plan: NexusDukePlan,
|
|
actions: NexusDukeStep[] = []): NexusDukeReturn {
|
|
assert.equal(dukeController(state.dukeVidal!), plan.captor);
|
|
let game = closeNexusDukeDraw(state, plan, actions).game;
|
|
const deaths: NexusDukeLeaderDeath[] = [];
|
|
for (const target of [...nexusDukePlayer(game, plan.captor).leaders]
|
|
.sort((a, b) => a.strength - b.strength)) {
|
|
const turn = game.turn;
|
|
plan.bid = nexusDukePlayer(game, plan.owner).hand.some(isWeapon) ? 0 : 1;
|
|
plan.capture = 'decline';
|
|
plan.ship = {
|
|
...(carthagForces(game, plan.owner) ? {} : { [plan.owner]: { territory: 'carthag', amount: 1 } }),
|
|
...(carthagForces(game, plan.captor) ? {} : { [plan.captor]: { territory: 'carthag', amount: 1 } }),
|
|
};
|
|
game = advanceNexusDuke(game, (candidate) =>
|
|
candidate.phase === 6 && nexusDukeClean(candidate) && !!candidate.active, plan, actions);
|
|
plan.ship = {};
|
|
plan.bid = 0;
|
|
const weapon = nexusDukePlayer(game, plan.owner).hand.find(isWeapon);
|
|
assert.ok(weapon, 'The real Bidding win must leave Ecaz holding a physical weapon.');
|
|
assert.ok(carthagForces(game, plan.owner) > 0 && carthagForces(game, plan.captor) > 0,
|
|
'Both original armies must really be present in the Battle territory.');
|
|
const battle = openNexusDukeBattle(game, plan, actions,
|
|
{ territory: 'carthag', first: plan.owner, second: plan.captor });
|
|
game = sealNexusDukeBattle(battle.game, plan, actions, [
|
|
{ actor: plan.owner, leader: strongest(living(game, plan.owner)).id, dial: 0, support: 0,
|
|
weapon: weapon.id },
|
|
{ actor: plan.captor, leader: target.id, dial: 0, support: 0 },
|
|
]);
|
|
game = advanceNexusDuke(game, (candidate) =>
|
|
!candidate.battle && !candidate.decision && nexusDukeClean(candidate), plan, actions);
|
|
assert.equal(nexusDukePlayer(game, plan.captor).leaders
|
|
.find((leader) => leader.id === target.id)!.dead, true,
|
|
`The real ${weapon.name} must kill the committed Harkonnen leader.`);
|
|
deaths.push({ leader: target.id, weapon: weapon.id, turn });
|
|
if (deaths.length < nexusDukePlayer(game, plan.captor).leaders.length) {
|
|
game = advanceNexusDuke(game, (candidate) => candidate.turn > turn && candidate.phase === 1 &&
|
|
nexusDukeClean(candidate), plan, actions);
|
|
orderE3NexusEcazSpice(game);
|
|
}
|
|
}
|
|
assert.deepEqual(living(game, plan.captor), [], 'Every native Harkonnen leader must really be dead.');
|
|
assert.equal(game.dukeVidal!.leader.capturedBy, undefined,
|
|
'The aftermath that killed the last native leader must return the captive.');
|
|
return { game, deaths, returned: game, duke: structuredClone(game.dukeVidal!) };
|
|
}
|
|
|
|
/** Shared custody invariants: one shared disc, no fabricated roster/traitor
|
|
* membership, conserved physical cards and forces. */
|
|
export function assertNexusDukeCustody(game: Game): void {
|
|
for (const player of game.players) {
|
|
assert.deepEqual(player.leaders.map((leader) => leader.id).sort(),
|
|
leaders(player.faction).map((leader) => leader.id).sort(), `${player.faction} roster`);
|
|
assert.ok(!player.leaders.some((leader) => leader.id === DUKE_VIDAL_ID),
|
|
'The shared disc never joins a native roster.');
|
|
assert.equal(player.reserves + player.tanks +
|
|
Object.values(player.forces).reduce((sum, count) => sum + count, 0), 20, player.faction);
|
|
}
|
|
const traitors = [...(game.traitorReserve ?? []),
|
|
...game.players.flatMap((player) => [...player.traitors, ...player.traitorChoices]),
|
|
...(game.ecazLoyalty?.card ? [game.ecazLoyalty.card] : []),
|
|
...(game.moritaniAssassinate?.opportunities.filter(record => record.stage === 'replaced').map(record => record.card!) ?? [])];
|
|
assert.deepEqual(traitors.sort(), traitorDeck(game.players).sort());
|
|
assert.ok(!traitors.includes(DUKE_VIDAL_ID), 'The shared disc has no Traitor identity.');
|
|
const physical = [...game.deck, ...game.discard, ...game.players.flatMap((player) => player.hand),
|
|
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])];
|
|
assert.deepEqual(physical.map((card) => card.id).sort(), treacheryDeck(['ecaz']).map((card) => card.id).sort());
|
|
assert.equal(game.dukeVidal!.leader.id, DUKE_VIDAL_ID);
|
|
assert.equal(game.dukeVidal!.leader.strength, 6);
|
|
assert.equal(game.dukeVidal!.leader.faction, 'ecaz');
|
|
assert.ok(!game.players.some((player) => player.leaders.some((leader) => leader.id === DUKE_VIDAL_ID)));
|
|
if (game.ecazAmbassadors) validateAmbassadors(game.ecazAmbassadors);
|
|
}
|