255 lines
13 KiB
TypeScript
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));
|
|
});
|