Files
Dune/tests/ecaz-duke-revival.test.ts
Quintine 677ad7aa3c Compose independent cards with Discovery and optional Nexus
Connect original selected-deck Discovery setup, optional Nexus/World/Skills/phase modules and physical card consumers through shared public capabilities. Retain Cunning revealed-location arrival, Sandtrout suppression, paid nested R/Harass and actual later Recruits revival. Preserve native trained-capture and unrelated-preview boundaries.

Validation: frozen 15-file118/118, types/lint/build and six finite original-action/rendered-control programmes; four minimal legal policies. External smoke corrections changed no application source. Existing persistent notices also rechecked23/23. Full combinations, assurance and deployed acceptance remain open; no saved-game, restart or deployment operation.
2026-10-18 11:48:45 +11:00

561 lines
19 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, createGame, newPlayer, viewGame, type Game } from '../game/engine';
import { FACTIONS, type FactionId } from '../game/catalog';
import {
createDukeVidal,
acquireDuke,
consumeDuke,
DUKE_VIDAL_ID,
} from '../game/duke-vidal';
import { newRevivalRules, revivalDiscount } from '../game/revival';
import {
resolveEcazDukeRevival,
quoteEcazDukeRevival,
ecazDukeRevivalBlock,
EcazDukeRevivalError,
DUKE_REVIVAL_NORMAL_COST,
} from '../game/ecaz-duke-revival';
import { baseDeck } from '../game/cards';
import { quoteRevivalCancellation } from '../game/revival-cancellation';
import { botActions } from '../game/bots';
const p = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
function fixture(advanced = false) {
const g = createGame(
'DUKEREVIVAL',
newPlayer('ec', 'Ecaz', 'ecaz'),
advanced,
['ecaz'],
);
g.players.push(newPlayer('m', 'Moritani', 'moritani'));
Object.assign(g, {
status: 'playing',
phase: 4,
turn: 3,
order: ['ec', 'm'],
dukeVidal: createDukeVidal(),
revivalRules: newRevivalRules(),
});
g.dukeVidal!.leader.dead = true;
g.dukeVidal!.leader.deaths = 1;
return g;
}
void test('independent Discovery without Nexus admits the untrained Advanced Ecaz/Harkonnen revival quote', () => {
const g = fixture(true);
g.players[1] = newPlayer('h', 'Harkonnen', 'harkonnen');
g.ecazTreachery = true;
g.discoveryEnabled = true;
assert.equal(g.nexusCards, undefined);
assert.equal(quoteEcazDukeRevival(g, 'ec', false).cost, 5);
const excluded = { ...g, leaderSkills: {} };
assert.throws(() => quoteEcazDukeRevival(excluded as Game, 'ec', false), EcazDukeRevivalError);
});
void test('canonical dead shared Duke resolves without manufacturing or reviving it and always has printed normal price five', () => {
for (const advanced of [false, true]) {
const g = fixture(advanced);
g.dukeVidal!.leader.deaths = 4;
g.dukeVidal!.leader.usedAt = 'carthag';
const before = structuredClone(g),
oldRandom = Math.random;
let q;
try {
Math.random = () => {
throw new Error('shared identity quote must not sample');
};
q = resolveEcazDukeRevival(g, 'ec');
} finally {
Math.random = oldRandom;
}
assert.deepEqual(q, {
source: 'sharedDuke',
player: 'ec',
leader: DUKE_VIDAL_ID,
normalCost: 5,
duke: g.dukeVidal,
});
assert.equal(q.duke.leader.dead, true);
assert.equal(q.duke.controller, null);
assert.equal(q.duke.leader.strength, 6);
assert.equal(DUKE_REVIVAL_NORMAL_COST, 5);
assert.deepEqual(g, before);
q.duke.leader.deaths = 99;
q.duke.controller = 'ec';
assert.deepEqual(g, before);
}
});
void test('all eleven non-Ecaz factions are rejected, including an old Moritani controller and Tleilaxu foreign-ghola actor', () => {
for (const faction of FACTIONS.filter((f) => f.id !== 'ecaz')) {
const g = fixture();
g.players[1] = newPlayer('other', faction.name, faction.id as FactionId);
if (faction.id === 'moritani') {
g.dukeVidal = acquireDuke(createDukeVidal(), 'other', 1, 'moritani');
g.dukeVidal.leader.dead = true;
g.dukeVidal.leader.deaths = 1;
}
const before = structuredClone(g);
assert.throws(
() => resolveEcazDukeRevival(g, 'other'),
EcazDukeRevivalError,
faction.id,
);
assert.throws(
() => quoteEcazDukeRevival(g, 'other', false),
EcazDukeRevivalError,
faction.id,
);
assert.deepEqual(g, before);
}
const absent = fixture();
absent.players = absent.players.filter((p) => p.id !== 'ec');
assert.throws(
() => resolveEcazDukeRevival(absent, 'ec'),
EcazDukeRevivalError,
);
});
void test('existing native death count, phase, ordinary slot, prevention and private resource fields are outside identity resolution', () => {
const g = fixture();
for (const phase of [0, 4, 6, 8])
for (const deadCount of [0, 4, 5]) {
g.phase = phase;
p(g, 'ec').leaderRevived = true;
p(g, 'ec').revivalCycle = 7;
g.revivalPrevention = { turn: g.turn, player: 'ec' };
p(g, 'ec').leaders.forEach((l, i) => {
l.dead = i < deadCount;
l.deaths = l.dead ? 1 : 0;
});
const before = structuredClone(g);
assert.equal(resolveEcazDukeRevival(g, 'ec').normalCost, 5);
assert.deepEqual(g, before);
}
// Getters establish that the identity guard does not inspect denial/price
// eligibility or hidden native histories to infer whether it may revive.
for (const key of ['phase', 'revivalPrevention', 'revivalRules'] as const)
Object.defineProperty(g, key, {
get() {
throw new Error('not an identity prerequisite');
},
});
for (const seat of g.players) {
for (const key of [
'spice',
'hand',
'traitors',
'leaderRevived',
'revivalCycle',
'ally',
] as const)
Object.defineProperty(seat, key, {
get() {
throw new Error('not an identity prerequisite');
},
});
for (const leader of seat.leaders)
for (const key of ['dead', 'deaths', 'capturedBy', 'gholaBy'] as const)
Object.defineProperty(leader, key, {
get() {
throw new Error('private native history');
},
});
}
assert.equal(resolveEcazDukeRevival(g, 'ec').leader, DUKE_VIDAL_ID);
});
void test('declared discount composes the existing Tleilaxu rule to three spice without changing strength or the normal five-spice quote', () => {
const g = fixture(true);
g.players.push(newPlayer('t', 'Tleilaxu', 'tleilaxu'));
p(g, 't').ally = 'ec';
p(g, 'ec').ally = 't';
g.revivalRules!.allyDiscount = 'ec';
const discounted = quoteEcazDukeRevival(
g,
'ec',
!!revivalDiscount(g, p(g, 'ec')),
);
assert.equal(discounted.cost, 3);
assert.equal(discounted.normalCost, 5);
assert.equal(discounted.duke.leader.strength, 6);
g.revivalRules!.discountBlocked = true;
const full = quoteEcazDukeRevival(g, 'ec', !!revivalDiscount(g, p(g, 'ec')));
assert.equal(full.cost, 5);
assert.equal(full.normalCost, 5);
assert.equal(
discounted.cost,
3,
'a prior detached quote is not repriced by reading current entitlement',
);
assert.equal(resolveEcazDukeRevival(g, 'ec').normalCost, 5);
assert.throws(
() => quoteEcazDukeRevival(g, 'ec', undefined as never),
EcazDukeRevivalError,
);
assert.throws(
() => quoteEcazDukeRevival(g, 'ec', 1 as never),
EcazDukeRevivalError,
);
});
void test('missing, duplicate, forged and malformed shared physical history are rejected with one generic unavailable reason', () => {
const changes: [string, (g: Game) => void][] = [
[
'missing',
(g) => {
delete g.dukeVidal;
},
],
[
'native duplicate',
(g) => {
p(g, 'ec').leaders.push(structuredClone(g.dukeVidal!.leader));
},
],
[
'foreign duplicate',
(g) => {
p(g, 'm').leaders.push(structuredClone(g.dukeVidal!.leader));
},
],
[
'wrong ID',
(g) => {
g.dukeVidal!.leader.id = 'ecaz-1';
},
],
[
'wrong faction',
(g) => {
g.dukeVidal!.leader.faction = 'moritani';
},
],
[
'wrong strength',
(g) => {
g.dukeVidal!.leader.strength = 5;
},
],
[
'alive',
(g) => {
g.dukeVidal!.leader.dead = false;
},
],
[
'zero deaths',
(g) => {
g.dukeVidal!.leader.deaths = 0;
},
],
[
'negative deaths',
(g) => {
g.dukeVidal!.leader.deaths = -1;
},
],
[
'unsafe deaths',
(g) => {
g.dukeVidal!.leader.deaths = Number.MAX_SAFE_INTEGER + 1;
},
],
[
'fractional deaths',
(g) => {
g.dukeVidal!.leader.deaths = 1.5;
},
],
[
'malformed usedAt',
(g) => {
g.dukeVidal!.leader.usedAt = 5 as never;
},
],
[
'unknown controller',
(g) => {
g.dukeVidal!.controller = 'absent';
},
],
[
'missing source',
(g) => {
g.dukeVidal!.controller = 'm';
g.dukeVidal!.acquiredTurn = 1;
},
],
[
'orphan turn',
(g) => {
g.dukeVidal!.acquiredTurn = 1;
},
],
[
'future custody',
(g) => {
g.dukeVidal!.controller = 'm';
g.dukeVidal!.source = 'moritani';
g.dukeVidal!.acquiredTurn = 4;
},
],
];
for (const [label, change] of changes) {
const g = fixture();
change(g);
const before = structuredClone(g);
assert.throws(
() => resolveEcazDukeRevival(g, 'ec'),
EcazDukeRevivalError,
label,
);
assert.equal(
ecazDukeRevivalBlock(g, 'ec'),
'Duke Vidal is unavailable for this revival.',
label,
);
assert.deepEqual(g, before);
}
});
void test('captured, ghola and concealed records give identical reasons independent of hidden dead/capture variants', () => {
const descriptors = new Set<string | null>();
for (const marker of ['capturedBy', 'gholaBy', 'concealed'] as const)
for (const dead of [false, true]) {
const g = fixture();
g.dukeVidal!.leader.dead = dead;
if (marker === 'concealed')
g.dukeVidal!.leader.concealed = {
captor: 'm',
controller: 'ec',
dead: true,
deaths: 1,
};
else g.dukeVidal!.leader[marker] = 'm';
const before = structuredClone(g);
descriptors.add(ecazDukeRevivalBlock(g, 'ec'));
assert.throws(
() => resolveEcazDukeRevival(g, 'ec'),
EcazDukeRevivalError,
);
assert.deepEqual(g, before);
}
assert.deepEqual(
[...descriptors],
['Duke Vidal is unavailable for this revival.'],
);
});
void test('Advanced Harkonnen retains its uniform public boundary while Basic and other Advanced tables resolve normally', () => {
const g = fixture();
g.players.push(newPlayer('h', 'Harkonnen', 'harkonnen'));
assert.equal(ecazDukeRevivalBlock(g, 'ec'), null);
g.advanced = true;
const expected = ecazDukeRevivalBlock(g, 'ec');
assert.match(expected!, /Advanced Harkonnen/);
for (const dead of [false, true])
for (const captured of [false, true]) {
g.dukeVidal!.leader.dead = dead;
if (captured) g.dukeVidal!.leader.capturedBy = 'h';
else delete g.dukeVidal!.leader.capturedBy;
assert.equal(ecazDukeRevivalBlock(g, 'ec'), expected);
}
});
void test('set-aside battle-death identity and valid retained legacy custody resolve to the same reviver without selecting future control', () => {
const g = fixture();
g.dukeVidal = acquireDuke(createDukeVidal(), 'm', 2, 'moritani');
g.dukeVidal.leader.dead = true;
g.dukeVidal.leader.deaths = 2;
g.dukeVidal.leader.usedAt = 'arrakeen';
const retained = resolveEcazDukeRevival(g, 'ec');
assert.equal(retained.duke.controller, 'm');
g.dukeVidal = consumeDuke(g.dukeVidal);
const released = resolveEcazDukeRevival(g, 'ec');
assert.equal(released.duke.controller, null);
assert.equal(retained.player, released.player);
assert.equal(released.player, 'ec');
assert.deepEqual(retained.duke.leader, released.duke.leader);
assert.deepEqual(
resolveEcazDukeRevival(JSON.parse(JSON.stringify(g)), 'ec'),
released,
);
});
void test('a dead Duke with prior Ecaz Nexus custody resolves to the same physical return', () => {
const g = fixture();
g.dukeVidal = acquireDuke(createDukeVidal(), 'ec', 2, 'ecazNexus');
g.dukeVidal.leader.dead = true;
g.dukeVidal.leader.deaths = 1;
const quoted = resolveEcazDukeRevival(g, 'ec');
assert.equal(quoted.duke.source, 'ecazNexus');
assert.equal(quoted.normalCost, 5);
});
void test('invalid source context and duplicate Ecaz owners fail independently from paid revival timing', () => {
for (const change of [
(g: Game) => {
g.status = 'setup';
},
(g: Game) => {
g.turn = 0;
},
(g: Game) => {
g.turn = NaN;
},
(g: Game) => {
g.players.push(newPlayer('another', 'Second Ecaz', 'ecaz'));
},
(g: Game) => {
g.players.push(newPlayer('ec', 'Duplicate ID', 'moritani'));
},
(g: Game) => {
p(g, 'ec').leaders = null as never;
},
]) {
const g = fixture();
change(g);
const before = structuredClone(g);
assert.throws(() => resolveEcazDukeRevival(g, 'ec'), EcazDukeRevivalError);
assert.deepEqual(g, before);
}
});
void test('four native deaths plus Duke open Ecaz first cohort, which stays open after a paid return', () => {
let g = fixture();
p(g, 'ec').leaders.slice(0, 4).forEach((l) => {
l.dead = true;
l.deaths = 1;
});
p(g, 'ec').spice = 20;
const initial = viewGame(g, 'ec').revival.leaders;
assert.equal(initial.filter((l) => l.id !== DUKE_VIDAL_ID).length, 4);
assert.equal(initial.find((l) => l.id === DUKE_VIDAL_ID)?.early, false);
g = applyAction(g, 'ec', { type: 'reviveLeader', leader: DUKE_VIDAL_ID });
assert.equal(p(g, 'ec').revivalCycle, 1);
assert.equal(p(g, 'ec').spice, 15);
p(g, 'ec').leaderRevived = false;
g.turn++;
assert.equal(viewGame(g, 'ec').revival.leaders.length, 4);
const native = p(g, 'ec').leaders[0].id;
g = applyAction(g, 'ec', { type: 'reviveLeader', leader: native });
assert.equal(p(g, 'ec').spice, 15 - p(g, 'ec').leaders[0].strength);
assert.equal(p(g, 'ec').leaders[4].dead, false);
assert.equal(g.dukeVidal!.controller, null);
});
void test('saved discounted Duke request can be canceled back to five and paid once with income', () => {
let g = fixture(true);
g.players.push(newPlayer('t', 'Tleilaxu', 'tleilaxu'));
g.players.push(newPlayer('k', 'Karama', 'emperor'));
g.order = g.players.map((seat) => seat.id);
g.deck = baseDeck();
const karamaIndex = g.deck.findIndex((card) => card.effect === 'karama');
p(g, 'k').hand.push(g.deck.splice(karamaIndex, 1)[0]);
p(g, 'ec').spice = 8;
p(g, 't').ally = 'ec';
p(g, 'ec').ally = 't';
p(g, 't').specialKaramaUsed = true;
g = applyAction(g, 't', { type: 'tleilaxuAllyDiscount' });
assert.equal(viewGame(g, 'ec').revival.leaders.find((l) => l.id === DUKE_VIDAL_ID)?.cost, 3);
g = applyAction(g, 'ec', { type: 'reviveLeader', leader: DUKE_VIDAL_ID });
assert.deepEqual(g.pendingRevival?.checks, []);
assert.equal(g.response?.kind, 'revivalDiscount');
assert.equal(g.pendingRevival?.normalCost, 5);
assert.equal(g.pendingRevival?.cost, 3);
const saved = JSON.parse(JSON.stringify(g)) as Game;
const before = structuredClone(saved);
const quote = quoteRevivalCancellation(saved, saved.response!);
assert.equal(quote.pending?.cost, 5);
assert.deepEqual(saved, before);
g = applyAction(saved, 'k', {
type: 'card', card: p(saved, 'k').hand[0].id, mode: 'cancel',
});
assert.equal(p(g, 'ec').spice, 3);
assert.equal(g.dukeVidal!.leader.dead, false);
assert.equal(g.dukeVidal!.controller, null);
assert.equal(p(g, 'ec').leaderRevived, true);
assert.equal(g.pendingRevival, null);
assert.equal(g.response, null);
assert.equal(p(g, 't').spice, 5, 'the automatic paid revival income settles once');
});
void test('Advanced Tleilaxu stop decision survives JSON and cannot charge Duke before approval', () => {
let g = fixture(true);
g.players.push(newPlayer('t', 'Tleilaxu', 'tleilaxu'));
g.order = g.players.map((seat) => seat.id);
p(g, 'ec').spice = 7;
g = applyAction(g, 'ec', { type: 'reviveLeader', leader: DUKE_VIDAL_ID });
assert.equal(g.decision?.kind, 'revivalStop');
assert.equal(g.pendingRevival?.normalCost, 5);
assert.equal(p(g, 'ec').spice, 7);
assert.equal(g.dukeVidal!.leader.dead, true);
g = JSON.parse(JSON.stringify(g)) as Game;
g = applyAction(g, 't', { type: 'decision', decline: true });
assert.equal(p(g, 'ec').spice, 2);
assert.equal(p(g, 't').spice, 5);
assert.equal(g.dukeVidal!.leader.dead, false);
assert.equal(g.dukeVidal!.controller, null);
assert.equal(g.pendingRevival, null);
});
void test('all four Ecaz AI profiles can choose the authoritative five-spice Duke return', () => {
const g = fixture();
p(g, 'ec').spice = 10;
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
p(g, 'ec').bot = profile;
const view = viewGame(g, 'ec');
const selected = botActions(view).find(
(action) => action.type === 'reviveLeader' && action.leader === DUKE_VIDAL_ID,
);
assert.ok(selected, profile);
const done = applyAction(g, 'ec', selected);
assert.equal(done.dukeVidal!.leader.dead, false);
assert.equal(done.dukeVidal!.controller, null);
assert.equal(done.players.find((seat) => seat.id === 'ec')!.spice, 5);
}
});
void test('corrupt saved pending Duke custody rejects continuation without changing spice or the request', () => {
let g = fixture(true);
g.players.push(newPlayer('t', 'Tleilaxu', 'tleilaxu'));
g.order = g.players.map((seat) => seat.id);
p(g, 'ec').spice = 9;
g = applyAction(g, 'ec', { type: 'reviveLeader', leader: DUKE_VIDAL_ID });
assert.equal(g.decision?.kind, 'revivalStop');
g = JSON.parse(JSON.stringify(g)) as Game;
p(g, 'ec').leaders.push(structuredClone(g.dukeVidal!.leader));
const before = structuredClone(g);
assert.throws(() => applyAction(g, 't', { type: 'decision', decline: true }));
assert.deepEqual(g, before);
assert.equal(p(g, 'ec').spice, 9);
});
void test('five native Ecaz leaders in Tanks open the ordinary first cohort even while Duke lives', () => {
const g = fixture();
g.dukeVidal!.leader.dead = false;
g.dukeVidal!.leader.deaths = 0;
p(g, 'ec').leaders.forEach((l) => { l.dead = true; l.deaths = 1; });
p(g, 'ec').spice = 20;
const options = viewGame(g, 'ec').revival.leaders;
assert.equal(options.length, 5);
assert.equal(options.some((l) => l.id === DUKE_VIDAL_ID), false);
});
void test('unavailable captured native leaders retain the ordinary no-survivor exception', () => {
const g = fixture();
g.dukeVidal!.leader.dead = false;
g.dukeVidal!.leader.deaths = 0;
p(g, 'ec').leaders.forEach((l, i) => {
l.dead = i === 0;
l.deaths = i === 0 ? 1 : 0;
if (i !== 0) l.capturedBy = 'm';
});
p(g, 'ec').spice = 20;
assert.deepEqual(viewGame(g, 'ec').revival.leaders.map((l) => l.id),
[p(g, 'ec').leaders[0].id]);
});
void test('concealed foreign-ghola execution does not reveal Ecaz revival cohort history', () => {
const g = fixture(true);
g.expansions = ['ecaz', 'ix'];
g.players.push(newPlayer('t', 'Tleilaxu', 'tleilaxu'));
g.players.push(newPlayer('h', 'Harkonnen', 'harkonnen'));
const leader = p(g, 'ec').leaders[0];
leader.dead = false;
leader.deaths = 1;
leader.gholaBy = 't';
leader.capturedBy = 'h';
leader.concealed = { captor: 'h', controller: 't', dead: false, deaths: 1 };
const before = viewGame(g, 'ec');
leader.dead = true;
leader.deaths = 2;
delete leader.capturedBy;
const after = viewGame(g, 'ec');
assert.deepEqual(after.players.find((seat) => seat.id === 'ec')?.leaders,
before.players.find((seat) => seat.id === 'ec')?.leaders);
assert.deepEqual(after.revival, before.revival);
});