Files
Dune/tests/nexus-ecaz-duke-captured.test.ts

255 lines
13 KiB
TypeScript

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 NexusEcazDukeReceipt,
} 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 validates captured ordinary Ambassador tenure held by a former ally recipient', () => {
// Controlled custody input, separate from the native token/alliance source.
const g = position();
const shared = acquireDuke(g.dukeVidal!, 'g', 2, 'ecaz');
g.dukeVidal = captureDuke(shared, 'h', 'g', 3, 'carthag');
assert.equal(quoteNexusEcazDuke(g, 'e')!.blocked, null);
const receipt = createNexusEcazDukeReceipt(g, 'e');
assert.equal(receipt.before.controller, 'g');
assert.equal(receipt.before.source, 'ecaz');
assert.doesNotThrow(() => validateNexusEcazDuke(g, receipt, 0));
});
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 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');
type Captured = Extract<NexusEcazDukeReceipt, { custody: 'captured' }>;
const receipt = createNexusEcazDukeReceipt(g, 'e') as Captured;
const mutations: ((saved: Captured) => 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 }); },
];
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 without leaking actual prior custody', () => {
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 Duke support stays inside original no-Skills E3 profiles rather than lifting Skills guards', () => {
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']; },
]) {
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'));
}
});
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)!;
validateNexusEcazDuke(g, receipt, g.nexusEcazDukeHistory!.length - 1);
});
void test('legacy captured Battle records cannot be re-signed as earlier-phase liberation', () => {
const g = position();
g.dukeVidal = captureDuke(acquireDuke(g.dukeVidal!, 'g', 3, 'ally'), 'h', 'g', 3, 'carthag');
const fields = structuredClone(createNexusEcazDukeReceipt(g, 'e')) as unknown as Record<string, unknown>;
fields.version = 2;
delete fields.custody;
const legacy = fields as NexusEcazDukeReceipt;
legacy.signature = nexusEcazDukeSignature(legacy);
validateNexusEcazDuke(g, legacy, 0);
const invalid = structuredClone(legacy);
invalid.phase = 5;
invalid.event = nexusEcazDukeEvent(invalid.turn, invalid.phase, invalid.owner,
invalid.sequence, invalid.before.controller, invalid.before.acquiredTurn);
invalid.signature = nexusEcazDukeSignature(invalid);
assert.throws(() => validateNexusEcazDuke(g, invalid, 0));
});