Connect original Duke capture and Ecaz Nexus liberation
This commit is contained in:
1 parent
172363d9d3
commit
a24600ae1d
24 files changed
+2082
-235
No files matched your search
@@ -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'));
|
||||
}
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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=""|^$/);
|
||||
}
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user