Connect original Duke capture and Ecaz Nexus liberation

This commit is contained in:
Quintine committed 2026-10-15 13:24:47 +11:00
1 parent 172363d9d3
commit a24600ae1d
24 files changed
+2082 -235

No files matched your search

+183
View File
@@ -0,0 +1,183 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
acquireDuke, captureDuke, consumeDuke, createDukeVidal, dukeController,
executeCapturedDuke, expireDuke, releaseCapturedDuke, returnedDukeCapture, type DukeState,
} from '../game/duke-vidal';
void test('capture overrides effective control without replacing the unused original tenure or death history', () => {
for (const source of ['ecaz', 'moritani', 'ecazNexus', 'ally'] as const) {
const original = acquireDuke(createDukeVidal(), 'loser', 3, source);
original.leader.deaths = 2;
const before = structuredClone(original);
const captured = captureDuke(original, 'harkonnen', 'loser', 3, 'arrakeen');
assert.equal(dukeController(captured), 'harkonnen');
assert.deepEqual(original, before);
assert.deepEqual(releaseCapturedDuke(captured, 'harkonnen', 3), original);
assert.equal(dukeController(captured), 'harkonnen', 'pure release does not mutate the captured input');
}
});
void test('captivity preserves expired underlay but early release restores only still-valid tenure', () => {
for (const source of ['ecaz', 'moritani', 'ecazNexus', 'ally'] as const) {
const held = acquireDuke(createDukeVidal(), 'loser', 2, source);
const captured = captureDuke(held, 'harkonnen', 'loser', 2, 'carthag');
const afterTurn = expireDuke(captured, 2);
assert.deepEqual(afterTurn, captured);
const returned = releaseCapturedDuke(afterTurn, 'harkonnen', 3);
assert.equal(dukeController(returned), source === 'ecaz' ? 'loser' : null);
assert.equal(returned.source, source === 'ecaz' ? 'ecaz' : null);
assert.equal(returned.leader.capturedBy, undefined);
assert.equal(returned.leader.concealed, undefined);
if (source !== 'ecaz') {
assert.equal(returned.acquiredTurn, null);
const renewed = acquireDuke(returned, 'ecaz', 3, 'ecaz');
assert.equal(dukeController(renewed), 'ecaz');
}
}
});
void test('the original just-used losing Duke remains capturable after release only with a matching battle witness', () => {
const used = acquireDuke(createDukeVidal(), 'loser', 4, 'moritani');
used.leader.deaths = 3;
used.leader.usedAt = 'arrakeen';
const released = consumeDuke(used);
const witness = { deaths: 3, usedAt: 'arrakeen' };
assert.throws(() => captureDuke(released, 'h', 'loser', 4, 'arrakeen'));
assert.throws(() => captureDuke(used, 'h', 'loser', 4, 'arrakeen', witness));
const captured = captureDuke(released, 'h', 'loser', 4, 'arrakeen', witness);
assert.equal(dukeController(captured), 'h');
assert.throws(() => acquireDuke(captured, 'ecaz', 4, 'ecazNexus'));
const returned = releaseCapturedDuke(captured, 'h', 4);
assert.equal(dukeController(returned), null);
assert.equal(returned.leader.usedAt, 'arrakeen');
assert.equal(returned.leader.deaths, 3);
for (const [territory, evidence] of [
['carthag', witness],
['arrakeen', { deaths: 2, usedAt: 'arrakeen' }],
['arrakeen', { deaths: 3, usedAt: 'carthag' }],
] as const) {
assert.throws(() => captureDuke(released, 'h', 'loser', 4, territory, evidence));
}
});
void test('captive use and execution set aside the same disc rather than restoring an active native Ecaz leader', () => {
const original = acquireDuke(createDukeVidal(), 'ecaz', 1, 'ecaz');
original.leader.deaths = 2;
const captured = captureDuke(original, 'h', 'ecaz', 2, 'arrakeen');
for (const dead of [false, true]) {
const resolved = structuredClone(captured);
resolved.leader.usedAt = 'carthag';
resolved.leader.dead = dead;
if (dead) resolved.leader.deaths++;
const returned = consumeDuke(releaseCapturedDuke(resolved, 'h', 2));
assert.equal(dukeController(returned), null);
assert.equal(returned.leader.dead, dead);
assert.equal(returned.leader.deaths, dead ? 3 : 2);
assert.equal(returned.leader.capturedBy, undefined);
assert.equal(returned.leader.concealed, undefined);
if (dead) {
const resurrected = acquireDuke(returned, 'ecaz', 2, 'ecazNexus');
assert.equal(resurrected.leader.dead, false);
assert.equal(resurrected.leader.deaths, 3);
assert.equal(resurrected.leader.usedAt, undefined);
assert.equal(resurrected.source, 'ecazNexus');
}
}
});
void test('a secretly executed returned disc is revivable without treating its privacy snapshot as active captivity', () => {
const captured = captureDuke(acquireDuke(createDukeVidal(), 'loser', 2, 'ally'),
'h', 'loser', 2, 'arrakeen');
const before = structuredClone(captured);
const returned = executeCapturedDuke(captured, 'h', 2);
const hidden = captured.leader.concealed;
assert.deepEqual(captured, before, 'execution must not mutate the original capture');
assert.equal(returned.controller, null);
assert.equal(returned.source, null);
assert.equal(returned.acquiredTurn, null);
assert.equal(returned.leader.deaths, 2, 'original first-life Harkonnen execution advances to two deaths');
assert.equal(returned.leader.capturedBy, undefined);
assert.throws(() => executeCapturedDuke(captured, 'wrong', 2));
assert.throws(() => executeCapturedDuke(returned, 'h', 2));
assert.equal(returnedDukeCapture(returned), true);
assert.equal(dukeController(returned), null);
const revived = acquireDuke(returned, 'ecaz', 2, 'ecazNexus');
assert.equal(revived.leader.dead, false);
assert.equal(revived.leader.deaths, 2);
assert.equal(revived.leader.concealed, undefined);
assert.equal(revived.leader.capturedBy, undefined);
assert.equal(revived.captureVisibility, undefined);
assert.equal(revived.source, 'ecazNexus');
assert.deepEqual(returned.leader.concealed, hidden);
for (const mutate of [
(state: DukeState) => { state.leader.dead = false; },
(state: DukeState) => { state.leader.concealed!.deaths += 2; },
(state: DukeState) => { state.leader.gholaBy = 'tleilaxu'; },
(state: DukeState) => { state.leader.usedAt = 'carthag'; },
(state: DukeState) => { state.controller = 'loser'; },
(state: DukeState) => { state.captureVisibility!.controller = 'other'; },
(state: DukeState) => { state.captureVisibility!.source = null; },
(state: DukeState) => { delete state.captureVisibility; },
]) {
const broken = structuredClone(returned);
mutate(broken);
assert.equal(returnedDukeCapture(broken), false);
assert.throws(() => acquireDuke(broken, 'ecaz', 2, 'ecazNexus'));
}
});
void test('Cunning liberates actual captivity, never saves a captor to restore after its own expiry', () => {
const held = acquireDuke(createDukeVidal(), 'ecaz', 1, 'ecaz');
held.leader.deaths = 4;
const captive = captureDuke(held, 'h', 'ecaz', 3, 'arrakeen');
const liberated = acquireDuke(captive, 'ecaz', 3, 'ecazNexus');
assert.equal(dukeController(liberated), 'ecaz');
assert.equal(liberated.source, 'ecazNexus');
assert.equal(liberated.leader.capturedBy, undefined);
assert.equal(liberated.leader.concealed, undefined);
assert.equal(liberated.leader.deaths, 4);
assert.equal(dukeController(expireDuke(liberated, 3)), null);
assert.equal(dukeController(captive), 'h');
for (const source of ['ecaz', 'moritani', 'ally'] as const)
assert.throws(() => acquireDuke(captive, 'other', 3, source));
const foreign = structuredClone(captive);
foreign.leader.gholaBy = 'tleilaxu';
assert.throws(() => acquireDuke(foreign, 'ecaz', 3, 'ecazNexus'));
});
void test('capture and return reject wrong original controller, invalid tenure and corrupted physical custody without mutation', () => {
const held = acquireDuke(createDukeVidal(), 'loser', 2, 'moritani');
assert.throws(() => captureDuke(held, 'h', 'other', 2, 'arrakeen'));
assert.throws(() => captureDuke(held, 'h', 'loser', 3, 'arrakeen'));
const mutations: ((state: DukeState) => void)[] = [
state => { state.leader.dead = true; },
state => { state.leader.capturedBy = 'already'; },
state => { state.leader.gholaBy = 'tleilaxu'; },
state => { state.leader.id = 'replacement'; },
state => { state.leader.deaths = -1; },
state => { state.acquiredTurn = 3; },
state => { state.source = null; },
];
for (const mutate of mutations) {
const broken = structuredClone(held);
mutate(broken);
const before = structuredClone(broken);
assert.throws(() => captureDuke(broken, 'h', 'loser', 2, 'arrakeen'));
assert.deepEqual(broken, before);
}
const captive = captureDuke(held, 'h', 'loser', 2, 'arrakeen');
assert.throws(() => releaseCapturedDuke(captive, 'other', 2));
for (const mutate of [
(state: DukeState) => { state.leader.concealed!.captor = 'other'; },
(state: DukeState) => { state.leader.concealed!.controller = 'other'; },
(state: DukeState) => { state.leader.concealed!.deaths++; },
(state: DukeState) => { state.captureVisibility!.source = 'ecaz'; },
(state: DukeState) => { delete state.captureVisibility; },
]) {
const broken = structuredClone(captive);
mutate(broken);
assert.throws(() => releaseCapturedDuke(broken, 'h', 2));
assert.throws(() => acquireDuke(broken, 'ecaz', 2, 'ecazNexus'));
}
});
-18
View File
@@ -295,24 +295,6 @@ void test('unused Duke expires at turn end and a later eligible turn can acquire
assert.equal(reacquired.dukeVidal!.leader.id, DUKE_VIDAL_ID);
});
void test('advanced Harkonnen configurations uniformly gate unresolved Duke capture before acquiring', () => {
for (const hasCaptive of [false, true]) {
const g = fixture('harkonnen');
g.advanced = true;
if (hasCaptive) {
g.dukeVidal!.leader.capturedBy = 'e';
g.dukeVidal!.leader.concealed = {
captor: 'e',
controller: 'm',
dead: false,
deaths: 0,
};
}
const done = finishMovement(g);
assert.notEqual(done.response?.kind, 'moritaniDuke');
assert.equal(done.dukeVidal!.controller, null);
}
});
void test('every AI profile can legally plan with its shared Duke when no native leader remains alive', () => {
for (const difficulty of DIFFICULTIES) {
+732
View File
@@ -0,0 +1,732 @@
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] : [])];
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);
}
+234
View File
@@ -0,0 +1,234 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame } from '../game/engine';
import { DUKE_VIDAL_ID, dukeController } from '../game/duke-vidal';
import {
advanceNexusDuke, assertNexusDukeCustody, createNexusDukeCaptureFixture,
createNexusDukeDeathFixture, nexusDukeClean, nexusDukePlayer, quietNexusDukeWindow,
reclaimCapturedNexusDuke, releaseCapturedNexusDukeByAllHarkDead,
releaseCapturedNexusDukeByUse, openNexusDukeBattle, sealNexusDukeBattle, stepNexusDuke,
} from './fixture-nexus-duke-capture';
void test('a real Harkonnen Battle win captures the unused shared Duke', () => {
const fixture = createNexusDukeCaptureFixture({ variant: 'unused' });
const { owner, captor, game } = fixture;
assert.ok(fixture.candidates.some((leader) => leader.id === DUKE_VIDAL_ID),
'The shared disc must be in the actual original capture pool.');
const duke = game.dukeVidal!;
assert.equal(dukeController(duke), captor);
assert.equal(duke.controller, owner, 'An unused disc keeps its underlying Ecaz tenure.');
assert.equal(duke.source, 'ecaz');
assert.equal(duke.leader.capturedBy, captor);
assert.equal(duke.leader.dead, false);
assert.equal(duke.leader.deaths, 0);
assert.equal(duke.leader.usedAt, undefined);
assert.equal(duke.leader.gholaBy, undefined);
// The just-used native losing leader keeps its own one-battle release.
assert.equal(nexusDukePlayer(fixture.game, owner).leaders
.filter((leader) => leader.usedAt === 'carthag').length, 1);
assertNexusDukeCustody(game);
});
void test('a just-used Duke is captured from its original battle release through the witness', () => {
const fixture = createNexusDukeCaptureFixture({ variant: 'used' });
const { captor, game } = fixture;
const duke = game.dukeVidal!;
assert.equal(dukeController(duke), captor);
assert.equal(duke.controller, null, 'A released battle disc keeps no underlying control.');
assert.equal(duke.acquiredTurn, null);
assert.equal(duke.source, null);
assert.equal(duke.leader.capturedBy, captor);
assert.equal(duke.leader.usedAt, 'carthag');
assert.equal(duke.leader.dead, false);
assert.equal(duke.leader.deaths, 0);
assertNexusDukeCustody(game);
});
void test('executing the captive disc pays the original bounty and hides the death from unrelated seats', () => {
const fixture = createNexusDukeCaptureFixture({ variant: 'used', mode: 'execute' });
const { owner, captor } = fixture;
const before = fixture.decision!.before, after = fixture.decision!.after;
const prior = before.dukeVidal!;
const duke = after.dukeVidal!;
assert.equal(prior.leader.deaths, 0);
assert.equal(duke.leader.dead, true);
assert.equal(duke.leader.deaths, Math.max(2, prior.leader.deaths + 1));
assert.equal(duke.leader.capturedBy, undefined);
assert.equal(dukeController(duke), null, 'An executed disc is set aside in the Tanks.');
assert.equal(duke.controller, null);
assert.equal(duke.source, null);
assert.equal(duke.acquiredTurn, null);
assert.equal(duke.leader.faction, 'ecaz');
assert.equal(duke.leader.strength, 6);
assert.equal(nexusDukePlayer(after, captor).spice,
nexusDukePlayer(before, captor).spice + 2, 'The captor is paid the printed 2 spice.');
assert.equal(nexusDukePlayer(after, owner).spice, nexusDukePlayer(before, owner).spice);
assert.equal(nexusDukePlayer(after, owner).leaders.length, 5);
const unrelated = fixture.others.find((id) => id !== owner && id !== captor)!;
assert.deepEqual(viewGame(after, unrelated).dukeVidal!.leader,
viewGame(before, unrelated).dukeVidal!.leader,
'A private execution never discloses the secret selection or death to unrelated seats.');
assert.equal(viewGame(after, owner).dukeVidal!.leader.dead, true);
assert.equal(viewGame(after, captor).dukeVidal!.leader.dead, true);
assertNexusDukeCustody(after);
});
void test('genuine Ecaz Nexus Cunning liberates the actual captured disc through the real closing draw', () => {
const fixture = createNexusDukeCaptureFixture({ variant: 'unused' });
const { owner, captor, plan } = fixture;
assert.equal(dukeController(fixture.game.dukeVidal!), captor);
const reclaim = reclaimCapturedNexusDuke(fixture.game, plan);
const holder = nexusDukePlayer(reclaim.before, owner);
const spice = holder.spice, forces = structuredClone(holder.forces), tanks = holder.tanks;
const revived = holder.leaderRevived;
const duke = reclaim.after.dukeVidal!;
assert.equal(dukeController(duke), owner);
assert.equal(duke.controller, owner);
assert.equal(duke.source, 'ecazNexus');
assert.equal(duke.leader.capturedBy, undefined);
assert.equal(duke.leader.concealed, undefined);
assert.equal(duke.leader.dead, false);
assert.equal(duke.leader.deaths, fixture.duke.leader.deaths, 'Liberation preserves death history.');
assert.equal(duke.leader.gholaBy, undefined);
assert.ok(reclaim.after.nexusCards!.cards!.discard.includes('ecaz'),
'The physical Ecaz Nexus card is spent by the play.');
const liberator = nexusDukePlayer(reclaim.after, owner);
assert.equal(liberator.spice, spice, 'Liberation costs no spice.');
assert.deepEqual(liberator.forces, forces);
assert.equal(liberator.tanks, tanks);
assert.equal(liberator.leaderRevived, revived, 'Liberation spends no ordinary leader slot.');
assert.equal(liberator.leaders.length, 5);
assert.equal(nexusDukePlayer(reclaim.after, captor).spice, nexusDukePlayer(reclaim.before, captor).spice);
assertNexusDukeCustody(reclaim.after);
});
void test('a liberated Duke fights once through the native battle path', () => {
const fixture = createNexusDukeCaptureFixture({ variant: 'unused' });
const { owner, captor, plan, actions } = fixture;
const reclaim = reclaimCapturedNexusDuke(fixture.game, plan, actions, { battle: true });
const battle = openNexusDukeBattle(reclaim.after, plan, actions,
{ territory: 'carthag', first: owner, second: captor });
const enemy = nexusDukePlayer(battle.game, captor).leaders
.filter((leader) => !leader.dead).sort((a, b) => a.strength - b.strength)[0];
assert.ok(enemy);
const revealed = sealNexusDukeBattle(battle.game, plan, actions, [
{ actor: owner, leader: DUKE_VIDAL_ID, dial: 0, support: 0 },
{ actor: captor, leader: enemy.id, dial: 0, support: 0 },
]);
const after = advanceNexusDuke(revealed, (game) => !game.battle && nexusDukeClean(game), plan, actions);
assert.equal(after.dukeVidal!.leader.dead, false);
assert.equal(after.dukeVidal!.leader.deaths, fixture.duke.leader.deaths);
assert.equal(after.dukeVidal!.leader.usedAt, 'carthag');
assert.equal(dukeController(after.dukeVidal!), null);
assert.equal(after.dukeVidal!.leader.capturedBy, undefined);
assert.equal(after.dukeVidal!.leader.concealed, undefined);
assertNexusDukeCustody(after);
});
void test('unused liberated Nexus tenure expires at the original turn end without restoring captivity', () => {
const fixture = createNexusDukeCaptureFixture({ variant: 'unused' });
const { plan, actions } = fixture;
const reclaim = reclaimCapturedNexusDuke(fixture.game, plan, actions);
const battle = openNexusDukeBattle(reclaim.after, plan, actions,
{ territory: 'carthag', first: fixture.owner, second: fixture.captor });
const own = nexusDukePlayer(battle.game, fixture.owner).leaders
.filter(leader => !leader.dead).sort((a, b) => b.strength - a.strength)[0];
const enemy = nexusDukePlayer(battle.game, fixture.captor).leaders
.filter(leader => !leader.dead).sort((a, b) => a.strength - b.strength)[0];
assert.ok(own && enemy);
const revealed = sealNexusDukeBattle(battle.game, plan, actions, [
{ actor: fixture.owner, leader: own.id, dial: 0, support: 0 },
{ actor: fixture.captor, leader: enemy.id, dial: 0, support: 0 },
]);
const after = advanceNexusDuke(revealed, (game) => game.turn > reclaim.after.turn, plan, actions);
assert.equal(dukeController(after.dukeVidal!), null);
assert.equal(after.dukeVidal!.controller, null);
assert.equal(after.dukeVidal!.source, null);
assert.equal(after.dukeVidal!.acquiredTurn, null);
assert.equal(after.dukeVidal!.leader.capturedBy, undefined);
assert.equal(after.dukeVidal!.leader.concealed, undefined);
assert.equal(after.dukeVidal!.leader.dead, false);
assert.equal(after.dukeVidal!.leader.deaths, fixture.duke.leader.deaths);
assertNexusDukeCustody(after);
});
void test('a later real Battle use releases the retained captive disc', () => {
const fixture = createNexusDukeCaptureFixture({ variant: 'unused' });
const { owner, plan } = fixture;
const release = releaseCapturedNexusDukeByUse(fixture.game, plan);
const duke = release.game.dukeVidal!;
assert.equal(duke.leader.capturedBy, undefined);
assert.equal(duke.leader.concealed, undefined);
assert.equal(dukeController(duke), null);
assert.equal(duke.controller, null);
assert.equal(duke.acquiredTurn, null);
assert.equal(duke.source, null);
assert.equal(duke.leader.dead, false);
assert.equal(duke.leader.deaths, fixture.duke.leader.deaths);
assert.equal(duke.leader.usedAt, 'imperial_basin',
'The one-battle use stamps the shared disc at the real Battle territory.');
assert.equal(nexusDukePlayer(release.game, owner).leaders.every((leader) => !leader.dead), true);
assertNexusDukeCustody(release.game);
});
void test('all native Harkonnen leaders dead returns the captive disc to its underlying Ecaz custody', () => {
const fixture = createNexusDukeCaptureFixture({ variant: 'unused' });
const { owner, captor, plan } = fixture;
const returned = releaseCapturedNexusDukeByAllHarkDead(fixture.game, plan);
assert.equal(returned.deaths.length, 5);
assert.deepEqual(returned.deaths.map((entry) => entry.leader).sort(),
nexusDukePlayer(fixture.game, captor).leaders.map((leader) => leader.id).sort());
assert.deepEqual(nexusDukePlayer(returned.game, captor).leaders.filter((leader) => !leader.dead), []);
const duke = returned.game.dukeVidal!;
assert.equal(duke.leader.capturedBy, undefined);
assert.equal(duke.leader.concealed, undefined);
assert.equal(dukeController(duke), owner,
'The early return restores the still-valid original Ecaz tenure.');
assert.equal(duke.controller, owner);
assert.equal(duke.source, 'ecaz');
assert.equal(duke.acquiredTurn, 1);
assert.equal(duke.leader.dead, false);
assert.equal(duke.leader.deaths, fixture.duke.leader.deaths);
assertNexusDukeCustody(returned.game);
});
void test('a genuine dead Duke is dealt the printed Ecaz Ghola and then taken by quiet Cunning', () => {
const death = createNexusDukeDeathFixture({
deal: (faction) => faction === 'ecaz' ? ['ghola']
: faction === 'harkonnen' ? ['worthless', 'poison'] : ['worthless'],
});
const { owner, plan } = death;
assert.equal(death.game.dukeVidal!.leader.dead, true);
assert.ok(death.deaths >= 1);
assert.equal(death.game.dukeVidal!.leader.capturedBy, undefined);
assert.equal(death.game.dukeVidal!.leader.gholaBy, undefined);
assert.equal(death.window.nexusCards!.cards!.hands[owner], 'ecaz');
const ghola = nexusDukePlayer(death.window, owner).hand.find((card) => card.effect === 'ghola');
assert.ok(ghola, 'The real setup deal must leave Ecaz holding the printed Ghola card.');
const slot = nexusDukePlayer(death.window, owner).leaderRevived;
const revived = stepNexusDuke(death.window, plan,
{ actor: owner, action: { type: 'card', card: ghola.id, leader: DUKE_VIDAL_ID } }, death.actions);
const disc = revived.dukeVidal!;
assert.equal(disc.leader.dead, false);
assert.equal(disc.leader.deaths, death.deaths, 'The Ghola return preserves death history.');
assert.equal(disc.leader.usedAt, undefined);
assert.equal(disc.leader.gholaBy, undefined, 'No foreign-Ghola custody is ever produced.');
assert.equal(disc.controller, null);
assert.equal(disc.source, null);
assert.equal(dukeController(disc), null);
assert.equal(nexusDukePlayer(revived, owner).leaderRevived, slot,
'The printed Ghola spends no ordinary leader-revival allowance.');
assert.equal(nexusDukePlayer(revived, owner).leaders.length, 5);
assertNexusDukeCustody(revived);
const window = quietNexusDukeWindow(revived, plan);
const taken = stepNexusDuke(window.game, plan, { actor: owner, action: window.action }, death.actions);
assert.equal(dukeController(taken.dukeVidal!), owner);
assert.equal(taken.dukeVidal!.source, 'ecazNexus');
assert.equal(taken.dukeVidal!.leader.capturedBy, undefined);
assert.equal(taken.dukeVidal!.leader.gholaBy, undefined);
assert.equal(taken.dukeVidal!.leader.deaths, death.deaths);
assert.equal(nexusDukePlayer(taken, owner).leaders.length, 5);
assert.ok(!taken.players.some((player) => player.leaders.some((leader) => leader.id === DUKE_VIDAL_ID)));
assert.equal(taken.players.flatMap((player) => player.traitors).includes(DUKE_VIDAL_ID), false);
assertNexusDukeCustody(taken);
});
+233
View File
@@ -0,0 +1,233 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import {
acquireDuke, captureDuke, consumeDuke, createDukeVidal, dukeController,
DUKE_VIDAL_ID, executeCapturedDuke, expireDuke,
} from '../game/duke-vidal';
import { createNexusCards, drawNexusCard } from '../game/nexus-cards';
import { quoteEcazDukeAcquisition } from '../game/ecaz-duke-acquisition';
import { resolveEcazDukeRevival } from '../game/ecaz-duke-revival';
import {
createNexusEcazDukeReceipt, nexusEcazDukeEvent, nexusEcazDukeModeSupported,
nexusEcazDukeSignature, quoteNexusEcazDuke, validateNexusEcazDuke,
type NexusEcazDukeCapturedReceipt,
} from '../game/nexus-ecaz-duke';
import { createNexusDukeDeathFixture } from './fixture-nexus-duke-capture';
/** A small pure-quote fixture; the separate Ghola test uses the genuine setup seam. */
function position(): Game {
const players = [
{ id: 'e', faction: 'ecaz' as const, ally: null, leaders: [] },
{ id: 'h', faction: 'harkonnen' as const, ally: null, leaders: [] },
{ id: 'g', faction: 'guild' as const, ally: null, leaders: [] },
];
const cards = createNexusCards(players, () => 0.5);
const index = cards.deck.indexOf('ecaz');
assert.ok(index >= 0);
cards.deck.unshift(...cards.deck.splice(index, 1));
return {
players, advanced: true, expansions: ['ecaz'],
nexusCards: { cards: drawNexusCard(cards, 'e', players, () => 0), phase: null },
status: 'playing', phase: 6, turn: 3, dukeVidal: createDukeVidal(),
} as unknown as Game;
}
void test('Cunning sees actual capture over its own underlying Ecaz tenure and liberates it for this turn only', () => {
const g = position();
const owned = acquireDuke(g.dukeVidal!, 'e', 1, 'ecaz');
owned.leader.deaths = 2;
g.dukeVidal = captureDuke(owned, 'h', 'e', 3, 'arrakeen');
assert.throws(() => quoteEcazDukeAcquisition(g, 'e'));
const offer = quoteNexusEcazDuke(g, 'e')!;
assert.equal(offer.blocked, null, 'underlying owner is not the captive effective controller');
assert.equal(offer.dukeController, 'h', 'original losing controller is entitled to its own capture');
const receipt = createNexusEcazDukeReceipt(g, 'e');
validateNexusEcazDuke(g, receipt, 0);
g.dukeVidal = acquireDuke(g.dukeVidal!, 'e', 3, 'ecazNexus');
assert.equal(dukeController(g.dukeVidal), 'e');
assert.equal(g.dukeVidal.source, 'ecazNexus');
assert.equal(g.dukeVidal.leader.capturedBy, undefined);
assert.equal(g.dukeVidal.leader.concealed, undefined);
g.dukeVidal = expireDuke(g.dukeVidal, 3);
assert.equal(dukeController(g.dukeVidal), null);
assert.equal(g.dukeVidal.leader.deaths, 2);
validateNexusEcazDuke(g, receipt, 0);
// Later death and another turn are not mutations of the historical card play.
g.dukeVidal.leader.dead = true;
g.dukeVidal.leader.deaths++;
g.turn = 4;
g.phase = 0;
validateNexusEcazDuke(g, JSON.parse(JSON.stringify(receipt)), 0);
});
void test('the owner-private offer masks a concealed captor unless Ecaz was the actual losing controller', () => {
const g = position();
g.dukeVidal = captureDuke(acquireDuke(g.dukeVidal!, 'g', 3, 'ally'), 'h', 'g', 3, 'carthag');
const offer = quoteNexusEcazDuke(g, 'e')!;
assert.equal(offer.blocked, null);
assert.equal(offer.dukeController, 'g');
assert.equal(offer.event.includes('"h"'), false);
assert.equal(quoteNexusEcazDuke(g, 'h'), null);
const record = createNexusEcazDukeReceipt(g, 'e');
validateNexusEcazDuke(g, record, 0);
delete g.dukeVidal.leader.concealed;
assert.equal(quoteNexusEcazDuke(g, 'e')!.dukeController, 'h', 'revealed custody is public');
});
void test('a released witness captive stays unavailable while used but can be liberated after the original turn stamp clears', () => {
const g = position();
const used = acquireDuke(g.dukeVidal!, 'g', 3, 'ally');
used.leader.usedAt = 'arrakeen';
used.leader.deaths = 3;
g.dukeVidal = captureDuke(consumeDuke(used), 'h', 'g', 3, 'arrakeen', {
deaths: 3, usedAt: 'arrakeen',
});
assert.notEqual(quoteNexusEcazDuke(g, 'e')!.blocked, null);
assert.throws(() => createNexusEcazDukeReceipt(g, 'e'));
g.turn = 4;
delete g.dukeVidal.leader.usedAt;
const receipt = createNexusEcazDukeReceipt(g, 'e');
validateNexusEcazDuke(g, receipt, 0);
const liberated = acquireDuke(g.dukeVidal, 'e', 4, 'ecazNexus');
assert.equal(dukeController(liberated), 'e');
assert.equal(liberated.leader.deaths, 3);
assert.equal(liberated.leader.usedAt, undefined);
assert.equal(dukeController(expireDuke(liberated, 4)), null);
});
void test('captured v2 receipts reject impossible custody and return transitions even after a fresh signature', () => {
const g = position();
g.dukeVidal = captureDuke(acquireDuke(g.dukeVidal!, 'g', 3, 'ally'), 'h', 'g', 3, 'carthag');
const receipt = createNexusEcazDukeReceipt(g, 'e');
assert.ok('version' in receipt);
const mutations: ((saved: NexusEcazDukeCapturedReceipt) => void)[] = [
saved => { saved.before.leader.capturedBy = 'g'; },
saved => { saved.before.leader.concealed!.controller = 'h'; },
saved => { saved.before.leader.concealed!.deaths++; },
saved => { saved.before.source = 'ecazNexus'; },
saved => { saved.before.acquiredTurn = 4; },
saved => { saved.after.leader.deaths++; },
saved => { saved.after.controller = 'h'; },
saved => { Object.assign(saved.after.leader, { capturedBy: 'h' }); },
saved => { Object.assign(saved.before.leader, { gholaBy: 'g' }); },
saved => { Object.assign(saved.before.leader.concealed!, { extra: 1 }); },
saved => { Object.assign(saved, { version: 3 }); },
];
for (const mutate of mutations) {
const saved = structuredClone(receipt);
mutate(saved);
saved.signature = nexusEcazDukeSignature(saved);
assert.throws(() => validateNexusEcazDuke(g, saved, 0));
}
const downgraded = structuredClone(receipt) as unknown as Record<string, unknown>;
delete downgraded.version;
assert.throws(() => validateNexusEcazDuke(g, downgraded as never, 0));
g.dukeVidal.leader.gholaBy = 'g';
assert.notEqual(quoteNexusEcazDuke(g, 'e')!.blocked, null);
assert.throws(() => createNexusEcazDukeReceipt(g, 'e'));
});
void test('a privately executed return remains a Tanks resurrection with the unchanged old receipt schema', () => {
const g = position();
const captured = captureDuke(acquireDuke(g.dukeVidal!, 'g', 3, 'ally'), 'h', 'g', 3, 'arrakeen');
g.dukeVidal = captured;
const priorOffer = quoteNexusEcazDuke(g, 'e')!;
g.dukeVidal = executeCapturedDuke(captured, 'h', 3);
const offer = quoteNexusEcazDuke(g, 'e')!;
assert.equal(offer.blocked, null);
assert.equal(offer.dukeController, 'g', 'private execution still masks the underlying public snapshot');
assert.equal(offer.event, priorOffer.event, 'private execution does not change an unrelated owner action token');
const receipt = createNexusEcazDukeReceipt(g, 'e');
assert.equal(receipt.event, nexusEcazDukeEvent(3, 6, 'e', 0, null, null),
'private history still binds actual Tanks custody rather than projected prior tenure');
validateNexusEcazDuke(g, receipt, 0);
g.dukeVidal = acquireDuke(g.dukeVidal, 'e', 3, 'ecazNexus');
assert.equal(g.dukeVidal.leader.dead, false);
assert.equal(g.dukeVidal.leader.deaths, 2);
assert.equal(g.dukeVidal.leader.concealed, undefined);
assert.equal(g.dukeVidal.captureVisibility, undefined);
validateNexusEcazDuke(g, receipt, 0);
});
void test('Advanced Harkonnen admission is only the existing standalone no-Skills profile, not a broader Duke exception', () => {
const g = position();
assert.equal(nexusEcazDukeModeSupported(g), true);
assert.equal(quoteEcazDukeAcquisition(g, 'e').source, 'ecaz');
g.dukeVidal!.leader.dead = true;
g.dukeVidal!.leader.deaths = 1;
assert.equal(resolveEcazDukeRevival(g, 'e').player, 'e');
assert.throws(() => resolveEcazDukeRevival(g, 'h'));
for (const change of [
(saved: Game) => { saved.leaderSkills = {} as Game['leaderSkills']; },
(saved: Game) => { saved.players[2].faction = 'moritani'; },
]) {
const excluded = structuredClone(g);
change(excluded);
assert.equal(nexusEcazDukeModeSupported(excluded), false);
assert.notEqual(quoteNexusEcazDuke(excluded, 'e')!.blocked, null);
assert.throws(() => resolveEcazDukeRevival(excluded, 'e'));
excluded.dukeVidal!.leader.dead = false;
assert.throws(() => quoteEcazDukeAcquisition(excluded, 'e'));
}
for (const phase of [4, 5, 7]) {
const outside = position();
outside.phase = phase;
assert.notEqual(quoteNexusEcazDuke(outside, 'e')!.blocked, null);
}
});
void test('the actual Ecaz Ghola Treachery return sets the shared disc aside before a separate Cunning acquisition', () => {
const death = createNexusDukeDeathFixture({
deal: faction => faction === 'ecaz' ? ['ghola']
: faction === 'harkonnen' ? ['worthless', 'poison'] : ['worthless'],
});
let g = death.window;
const owner = death.owner;
assert.equal(g.dukeVidal!.leader.dead, true);
const deaths = g.dukeVidal!.leader.deaths;
const ghola = g.players.find(player => player.id === owner)!.hand
.find(card => card.effect === 'ghola')!;
assert.ok(ghola, 'Ecaz retains the original setup-dealt physical Ghola through the real battle death');
const beforeSpice = g.players.find(player => player.id === owner)!.spice;
const beforeSlot = g.players.find(player => player.id === owner)!.leaderRevived;
const rosters = g.players.map(player => player.leaders.map(leader => leader.id));
const forces = structuredClone(g.players.map(player => ({
reserves: player.reserves, tanks: player.tanks, forces: player.forces, elites: player.elites,
})));
g = applyAction(g, owner, { type: 'card', card: ghola.id, leader: DUKE_VIDAL_ID });
for (let step = 0; g.response && step < g.players.length; step++) {
const responder = g.players.find(player => !g.response!.passed.includes(player.id))!;
g = applyAction(g, responder.id, { type: 'passResponse' });
}
assert.equal(g.response, null);
assert.equal(g.dukeVidal!.leader.dead, false);
assert.equal(g.dukeVidal!.leader.deaths, deaths);
assert.equal(g.dukeVidal!.leader.gholaBy, undefined);
assert.equal(g.dukeVidal!.leader.usedAt, undefined, 'the real Ghola effect clears prior battle use');
assert.equal(dukeController(g.dukeVidal!), null, 'Ghola grants no fabricated active native Ecaz control');
assert.equal(g.discard.filter(card => card.id === ghola.id).length, 1);
const offer = viewGame(g, owner).nexusEcazDuke!;
assert.equal(offer.blocked, null);
const revivedSlot = g.players.find(player => player.id === owner)!.leaderRevived;
assert.equal(revivedSlot, beforeSlot, 'Ghola does not spend the normal leader revival allowance');
const returned = g;
g = applyAction(g, owner, { type: 'nexusEcazDuke', event: offer.event });
assert.equal(dukeController(g.dukeVidal!), owner);
assert.equal(g.dukeVidal!.source, 'ecazNexus');
assert.equal(g.dukeVidal!.leader.deaths, deaths);
assert.equal(g.dukeVidal!.leader.gholaBy, undefined);
assert.equal(g.players.find(player => player.id === owner)!.leaderRevived, revivedSlot);
assert.equal(g.players.find(player => player.id === owner)!.spice, beforeSpice);
assert.deepEqual(g.players.map(player => player.leaders.map(leader => leader.id)), rosters);
assert.deepEqual(g.players.map(player => ({
reserves: player.reserves, tanks: player.tanks, forces: player.forces, elites: player.elites,
})), forces);
assert.equal(returned.nexusCards!.cards!.hands[owner], 'ecaz');
assert.equal(g.nexusCards!.cards!.hands[owner], null);
assert.equal(g.nexusCards!.cards!.discard.filter(card => card === 'ecaz').length, 1);
const receipt = g.nexusEcazDukeHistory!.at(-1)!;
assert.equal('version' in receipt, false, 'ordinary Ecaz Ghola return is not a foreign-Ghola or captive producer');
validateNexusEcazDuke(g, receipt, g.nexusEcazDukeHistory!.length - 1);
});
-97
View File
@@ -1,97 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { viewGame, type GameView } from '../game/engine';
import { NexusEcazDuke } from '../components/nexus-ecaz-duke';
import { nexusEcazDukeAction } from '../game/nexus-ecaz-duke-options';
import { consumeDuke } from '../game/duke-vidal';
import { nexusEcazDukePosition } from './fixture-nexus-ecaz-duke';
const event = '["nexusEcazDuke",2,6,"ecaz","moritani",0]';
function projected(overrides: Record<string, unknown> = {}): GameView {
return {
status: 'playing', phase: 6, turn: 2, me: 'ecaz', active: 'moritani',
advanced: false, battle: null, phaseOpening: null, response: null, decision: null,
truthtrance: null, automaticContinuationPending: false,
players: [
{ id: 'ecaz', name: 'House Ecaz', faction: 'ecaz', ally: null, bot: 'Easy' },
{ id: 'moritani', name: 'House Moritani', faction: 'moritani', ally: null },
],
dukeVidal: { controller: 'moritani' },
nexusCards: { card: 'ecaz', waiting: [] },
nexusEcazInquiry: { offer: null, history: [] },
nexusEcazDuke: { event, blocked: null, dukeController: 'moritani' },
...overrides,
} as unknown as GameView;
}
function markup(view: GameView, busy = false) {
return renderToStaticMarkup(createElement(NexusEcazDuke, { game: view, busy, act() {} }));
}
void test('private projection and physical card limit the offer without probing a rival hand', () => {
const game = projected();
Object.defineProperty(game.players[1], 'hand', {
get() { throw new Error('Rival private cards must remain unread'); },
});
const html = markup(game);
assert.match(html, /<button\b/);
assert.doesNotMatch(html, /disabled=""/);
assert.match(markup(game, true), /<button\b[^>]*disabled=""/);
const rival = projected({ me: 'moritani', nexusCards: { card: null }, nexusEcazDuke: null });
assert.equal(markup(rival), '');
assert.equal(nexusEcazDukeAction(rival), null);
assert.equal(markup(projected({ nexusCards: { card: null } })), '');
assert.equal(markup(projected({ nexusEcazDuke: null })), '');
});
void test('actual Tanked Duke gives the native owner an enabled event-bound control, never a rival offer', () => {
for (const advanced of [false, true]) {
const { game } = nexusEcazDukePosition(advanced);
// Explicit conserved Tanks position, not fabricated Nexus custody or a public offer.
game.dukeVidal = consumeDuke(game.dukeVidal!);
game.dukeVidal.leader.dead = true;
game.dukeVidal.leader.deaths = 2;
game.dukeVidal.leader.usedAt = 'carthag';
const owner = viewGame(game, 'p');
assert.match(markup(owner), /<button\b/);
assert.doesNotMatch(markup(owner), /disabled=""/);
assert.match(markup(owner, true), /<button\b[^>]*disabled=""/);
for (const seat of ['q', 'r']) {
const rival = viewGame(game, seat);
assert.equal(rival.nexusEcazDuke, null);
assert.equal(nexusEcazDukeAction(rival), null);
assert.equal(markup(rival), '');
}
}
});
void test('blocked offer remains explanatory but no interrupt, alliance, or started battle can spend it', () => {
const blocked = projected({ nexusEcazDuke: {
event, blocked: 'Duke Vidal is captured and cannot be taken.', dukeController: 'moritani',
} });
assert.equal(nexusEcazDukeAction(blocked), null);
assert.match(markup(blocked), /<button\b[^>]*disabled=""/);
for (const changes of [
{ status: 'finished' }, { phase: 5 }, { phaseOpening: { passed: [] } },
{ battle: { event: 'battle-already-chosen' } },
{ automaticContinuationPending: true }, { truthtrance: { player: 'ecaz' } },
{ response: { kind: 'karama' } }, { decision: { kind: 'battle' } },
{ nexusCards: { card: 'ecaz', waiting: ['moritani'] } },
{ nexusCards: { card: null, waiting: [] } },
{ roomControl: { paused: true } },
{ players: [
{ id: 'ecaz', faction: 'ecaz', ally: 'moritani' },
{ id: 'moritani', faction: 'moritani', ally: 'ecaz' },
] },
]) {
const view = projected(changes);
assert.equal(nexusEcazDukeAction(view), null);
assert.match(markup(view), /<button\b[^>]*disabled=""|^$/);
}
});
+5 -14
View File
@@ -160,9 +160,8 @@ void test('living already-used Duke cannot gain a new battle use from Ecaz Cunni
g.dukeVidal!.leader.usedAt = 'carthag';
g.dukeVidal!.leader.deaths = 2;
const before = structuredClone(g.dukeVidal);
assert.match(quoteNexusEcazDuke(g, 'ecaz-seat')!.blocked!, /already been used/);
assert.throws(() => createNexusEcazDukeReceipt(g, 'ecaz-seat'), /already been used/);
assert.throws(() => acquireDuke(g.dukeVidal!, 'ecaz-seat', 3, 'ecazNexus'), /already been used/);
assert.throws(() => createNexusEcazDukeReceipt(g, 'ecaz-seat'));
assert.throws(() => acquireDuke(g.dukeVidal!, 'ecaz-seat', 3, 'ecazNexus'));
assert.deepEqual(g.dukeVidal, before);
const ordinary = acquireDuke(g.dukeVidal!, 'moritani-seat', 3, 'moritani');
assert.deepEqual(ordinary.leader, before!.leader);
@@ -179,8 +178,7 @@ void test('Tanked acquisition requires set-aside custody and never bypasses capt
]) {
const duke = { ...g.dukeVidal!, ...change };
const before = structuredClone(duke);
assert.match(quoteNexusEcazDuke({ ...g, dukeVidal: duke }, 'ecaz-seat')!.blocked!, /set aside/);
assert.throws(() => acquireDuke(duke, 'ecaz-seat', 3, 'ecazNexus'), /set aside/);
assert.throws(() => acquireDuke(duke, 'ecaz-seat', 3, 'ecazNexus'));
assert.deepEqual(duke, before);
}
for (const dead of [false, true]) {
@@ -189,8 +187,6 @@ void test('Tanked acquisition requires set-aside custody and never bypasses capt
duke.leader.dead = dead;
duke.leader[custody] = 'moritani-seat';
const before = structuredClone(duke);
assert.match(quoteNexusEcazDuke({ ...g, dukeVidal: duke }, 'ecaz-seat')!.blocked!,
/uncaptured, non-Ghola/);
for (const source of ['moritani', 'ecaz', 'ecazNexus', 'ally'] as const)
assert.throws(() => acquireDuke(duke, 'ecaz-seat', 3, source));
assert.deepEqual(duke, before);
@@ -198,13 +194,12 @@ void test('Tanked acquisition requires set-aside custody and never bypasses capt
}
});
void test('dead receipts retain the old signature shape and reject non-set-aside Tanks history', () => {
void test('dead receipts reject non-set-aside Tanks history', () => {
const g = game();
g.dukeVidal!.leader.dead = true;
g.dukeVidal!.leader.deaths = 2;
const original = createNexusEcazDukeReceipt(g, 'ecaz-seat');
validateNexusEcazDuke(g, original, 0);
assert.equal(original.signature, nexusEcazDukeSignature(JSON.parse(JSON.stringify(original))));
for (const change of [
{ controller: 'moritani-seat', acquiredTurn: 3, source: 'moritani' as const },
{ acquiredTurn: 2 },
@@ -228,8 +223,6 @@ void test('ordinary Ecaz Ambassador custody survives end turn; Nexus custody doe
const ordinary = acquireDuke(g.dukeVidal!, 'ecaz-seat', 3, 'ecaz');
assert.equal(expireDuke(ordinary, 3).controller, 'ecaz-seat');
assert.equal(expireDuke(ordinary, 4).source, 'ecaz');
assert.equal(quoteNexusEcazDuke({ ...g, dukeVidal: ordinary }, 'ecaz-seat')?.blocked,
'Ecaz already controls Duke Vidal.');
const moritani = acquireDuke(ordinary, 'moritani-seat', 3, 'moritani');
const nexus = acquireDuke(moritani, 'ecaz-seat', 3, 'ecazNexus');
assert.equal(expireDuke(nexus, 2).source, 'ecazNexus');
@@ -238,8 +231,6 @@ void test('ordinary Ecaz Ambassador custody survives end turn; Nexus custody doe
const unavailable = structuredClone(nexus);
unavailable.leader[property] = 'moritani-seat';
assert.throws(() => acquireDuke(unavailable, 'ecaz-seat', 3, 'ecazNexus'));
assert.match(quoteNexusEcazDuke({ ...g, dukeVidal: unavailable }, 'ecaz-seat')!.blocked!,
/uncaptured, non-Ghola/);
}
});
@@ -299,7 +290,7 @@ void test('damaged and re-signed impossible history cannot forge the original Du
invalid(record => { record.sequence = 1; });
invalid(record => { record.event = nexusEcazDukeEvent(3, 6, 'ecaz-seat', 0, null, null); });
invalid(record => { (record as NexusEcazDukeReceipt & { extra: boolean }).extra = true; });
invalid(record => { record.before = { ...record.before, leader: { ...record.before.leader, id: 'other' as never } }; });
invalid(record => { record.before.leader.id = 'other' as never; });
assert.throws(() => validateNexusEcazDuke(g, null as never, 0));
assert.throws(() => validateNexusEcazDuke({ ...g, phase: 5 }, original, 0));
assert.throws(() => validateNexusEcazDuke({ ...g, nexusCards: null }, original, 0));