Files
Dune/tests/ecaz-duke-acquisition.test.ts
T

599 lines
21 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
viewGame,
RuleError,
type Game,
type Action,
} from '../game/engine';
import { baseDeck } from '../game/cards';
import {
createDukeVidal,
acquireDuke,
DUKE_VIDAL_ID,
} from '../game/duke-vidal';
import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors';
import {
quoteEcazDukeAcquisition,
ecazDukeAcquisitionBlock,
EcazDukeAcquisitionError,
} from '../game/ecaz-duke-acquisition';
import type { FactionId } from '../game/catalog';
const p = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
function fixture(entrant: FactionId = 'emperor', advanced = false) {
const g = createGame('ECAZDUKE', newPlayer('ec', 'Ecaz', 'ecaz'), advanced, [
'ecaz',
]);
g.players.push(
newPlayer('in', 'Entrant', entrant),
newPlayer('al', 'Ally', 'atreides'),
);
Object.assign(g, {
status: 'playing',
phase: 5,
turn: 2,
storm: 18,
active: 'in',
order: ['in', 'ec', 'al'],
movementRemaining: ['in', 'ec', 'al'],
deck: baseDeck(),
dukeVidal: createDukeVidal(),
});
for (const s of g.players)
Object.assign(s, {
forces: {},
reserves: 20,
spice: 20,
hand: [],
traitors: [],
traitorChoices: [],
});
const inventory = createAmbassadors(() => 0),
token = inventory.tokens.find((t) => t.effect === 'ecaz')!;
g.ecazAmbassadors = placeAmbassador(inventory, token.id, {
turn: 1,
availableSpice: 20,
destination: {
id: 'arrakeen',
stronghold: true,
inStorm: false,
allowed: true,
},
}).state;
return g;
}
const enter = (g = fixture()) =>
applyAction(g, 'in', {
type: 'ship',
territory: 'arrakeen',
sector: 10,
amount: 2,
});
function command(g: Game): Action {
return {
type: 'decision',
event: g.pendingAmbassador!.event,
trigger: true,
beneficiary: 'ec',
choice: 'duke',
};
}
const acquire = (g: Game) => applyAction(g, 'ec', command(g));
void test('independent Discovery without Nexus admits the untrained Advanced Ecaz/Harkonnen acquisition quote', () => {
const g = fixture('harkonnen', true);
g.ecazTreachery = true;
g.discoveryEnabled = true;
assert.equal(g.nexusCards, undefined);
assert.equal(quoteEcazDukeAcquisition(g, 'ec').controller, 'ec');
const excluded = { ...g, leaderSkills: {} };
assert.throws(() => quoteEcazDukeAcquisition(excluded as Game, 'ec'), EcazDukeAcquisitionError);
});
function reject(g: Game, id: string, action: Action) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, id, action), RuleError);
assert.deepEqual(g, before);
}
function responses(g: Game) {
for (let n = 0; g.response && n < 30; n++)
g = applyAction(
g,
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
{ type: 'passResponse' },
);
assert.equal(g.response, null);
return g;
}
void test('pure Ecaz acquisition preserves the exact shared physical disc, history and prior use while transferring custody', () => {
const g = fixture('moritani', true);
g.dukeVidal = acquireDuke(g.dukeVidal!, 'in', 1, 'moritani');
g.dukeVidal.leader.deaths = 2;
g.dukeVidal.leader.usedAt = 'carthag';
const before = structuredClone(g),
originalRandom = Math.random;
let quoted;
try {
Math.random = () => {
throw new Error('acquisition quote must not sample');
};
quoted = quoteEcazDukeAcquisition(g, 'ec');
} finally {
Math.random = originalRandom;
}
assert.deepEqual(quoted, {
...g.dukeVidal,
controller: 'ec',
acquiredTurn: 2,
source: 'ecaz',
});
assert.deepEqual(quoted.leader, g.dukeVidal.leader);
assert.deepEqual(g, before);
quoted.leader.deaths = 99;
assert.deepEqual(g, before);
assert.equal(ecazDukeAcquisitionBlock(g, 'ec'), null);
});
void test('pure source/canonical identity/custody checks reject safely, with one generic exceptional-custody reason', () => {
const base = fixture();
const reasons = new Set<string | null>();
for (const change of [
(g: Game) => {
delete g.dukeVidal;
},
(g: Game) => {
g.dukeVidal!.leader.dead = true;
},
(g: Game) => {
g.dukeVidal!.leader.capturedBy = 'in';
},
(g: Game) => {
g.dukeVidal!.leader.gholaBy = 'in';
},
(g: Game) => {
g.dukeVidal!.leader.id = 'forged';
},
(g: Game) => {
g.dukeVidal!.leader.strength = 5;
},
(g: Game) => {
g.dukeVidal!.leader.deaths = -1;
},
(g: Game) => {
g.dukeVidal!.controller = 'absent';
},
(g: Game) => {
g.dukeVidal!.acquiredTurn = 3;
},
]) {
const bad = reload(base);
change(bad);
const before = structuredClone(bad);
assert.throws(
() => quoteEcazDukeAcquisition(bad, 'ec'),
EcazDukeAcquisitionError,
);
reasons.add(ecazDukeAcquisitionBlock(bad, 'ec'));
assert.deepEqual(bad, before);
}
assert.deepEqual(
[...reasons],
['Duke Vidal is unavailable for this acquisition.'],
);
assert.throws(
() => quoteEcazDukeAcquisition(base, 'in'),
EcazDukeAcquisitionError,
);
base.status = 'setup';
assert.throws(
() => quoteEcazDukeAcquisition(base, 'ec'),
EcazDukeAcquisitionError,
);
});
void test('redundant same-owner and Advanced Harkonnen cases keep public reasons independent of private custody', () => {
const own = fixture();
own.dukeVidal = acquireDuke(own.dukeVidal!, 'ec', 1, 'ecaz');
const reason = ecazDukeAcquisitionBlock(own, 'ec');
assert.match(reason!, /already controls Duke Vidal through its Ambassador/);
own.dukeVidal.leader.capturedBy = 'in';
assert.equal(ecazDukeAcquisitionBlock(own, 'ec'), reason);
const hark = fixture('harkonnen', true);
const blocked = ecazDukeAcquisitionBlock(hark, 'ec');
assert.match(blocked!, /Advanced Harkonnen/);
hark.dukeVidal!.leader.capturedBy = 'in';
assert.equal(ecazDukeAcquisitionBlock(hark, 'ec'), blocked);
const basic = fixture('harkonnen', false);
assert.equal(ecazDukeAcquisitionBlock(basic, 'ec'), null);
});
void test('actual paid shipment to the reusable Ecaz Ambassador acquires once and resumes exact ordinary counters', () => {
for (const advanced of [false, true]) {
const initial = fixture('emperor', advanced);
p(initial, 'ec').ally = 'al';
p(initial, 'al').ally = 'ec';
initial.dukeVidal!.leader.deaths = 2;
const g = enter(initial),
before = reload(g),
cohort = [...g.ecazAmbassadors!.cohort];
assert.deepEqual(viewGame(g, 'ec').ambassadorEntry!.dukeAcquisition, {
blocked: null,
});
assert.equal(viewGame(g, 'in').ambassadorEntry!.dukeAcquisition, null);
const act = command(g),
done = applyAction(reload(g), 'ec', act);
assert.deepEqual(g, before);
assert.equal(done.pendingAmbassador, null);
assert.equal(done.decision, null);
assert.equal(done.dukeVidal!.controller, 'ec');
assert.equal(done.dukeVidal!.source, 'ecaz');
assert.equal(done.dukeVidal!.leader.deaths, 2);
assert.deepEqual(done.ecazAmbassadors!.cohort, cohort);
assert.deepEqual(
done.ecazAmbassadors!.tokens.find((t) => t.effect === 'ecaz'),
{
id: g.pendingAmbassador!.token,
effect: 'ecaz',
zone: 'supply',
location: null,
},
);
assert.deepEqual(done.players, g.players);
assert.equal(p(done, 'in').spice, 18);
assert.equal(p(done, 'in').reserves, 18);
assert.equal(p(done, 'in').shipped, true);
assert.equal(p(done, 'in').moved, 0);
assert.equal(done.active, 'in');
assert.deepEqual(done.movementRemaining, g.movementRemaining);
assert.deepEqual(done.deck, g.deck);
assert.ok(
done.players.every(
(p) =>
!p.leaders.some((l) => l.id === DUKE_VIDAL_ID) &&
!p.traitors.includes(DUKE_VIDAL_ID),
),
);
reject(done, 'ec', act);
}
});
void test('Moritani movement entry transfers its existing Duke to Ecaz without replaying movement', () => {
const initial = fixture('moritani');
initial.dukeVidal = acquireDuke(initial.dukeVidal!, 'in', 1, 'moritani');
p(initial, 'in').forces = { 'imperial_basin:10': 3 };
p(initial, 'in').reserves = 17;
p(initial, 'in').shipped = true;
const g = applyAction(initial, 'in', {
type: 'move',
forces: { 'imperial_basin:10': 2 },
territory: 'arrakeen',
sector: 10,
amount: 2,
});
assert.equal(g.pendingAmbassador?.entrant, 'in');
const before = reload(g),
done = acquire(reload(g));
assert.equal(done.dukeVidal!.controller, 'ec');
assert.deepEqual(done.players, before.players);
assert.equal(p(done, 'in').moved, 1);
assert.equal(p(done, 'in').forces['arrakeen:10'], 2);
assert.equal(p(done, 'in').forces['imperial_basin:10'], 1);
assert.equal(done.active, 'in');
});
void test('explicit Duke choice/owner/event and eligibility are checked before triggering the token', () => {
const g = enter();
const action = command(g);
reject(g, 'in', action);
reject(g, 'ec', { ...action, event: 'stale' });
reject(g, 'ec', { ...action, choice: undefined });
reject(g, 'ec', { ...action, choice: 'loan' });
reject(g, 'ec', { ...action, beneficiary: 'al' });
const allied = reload(g);
p(allied, 'ec').ally = 'al';
p(allied, 'al').ally = 'ec';
const shared = applyAction(allied, 'ec', { ...action, beneficiary: 'al' });
assert.equal(shared.dukeVidal!.controller, 'al');
assert.equal(shared.dukeVidal!.source, 'ecaz');
assert.deepEqual(shared.players, allied.players);
const staleAlly = reload(allied);
p(staleAlly, 'al').ally = null;
reject(staleAlly, 'ec', { ...action, beneficiary: 'al' });
for (const mutate of [
(b: Game) => {
delete b.dukeVidal;
},
(b: Game) => {
b.dukeVidal!.leader.dead = true;
},
(b: Game) => {
b.dukeVidal!.leader.capturedBy = 'in';
},
(b: Game) => {
b.dukeVidal!.leader.gholaBy = 'in';
},
(b: Game) => {
b.dukeVidal = acquireDuke(b.dukeVidal!, 'ec', 1, 'ecaz');
},
(b: Game) => {
b.advanced = true;
p(b, 'in').faction = 'harkonnen';
},
]) {
const bad = reload(g);
mutate(bad);
assert.ok(viewGame(bad, 'ec').ambassadorEntry!.dukeAcquisition!.blocked);
reject(bad, 'ec', action);
const done = applyAction(bad, 'ec', {
type: 'decision',
event: bad.pendingAmbassador!.event,
decline: true,
});
assert.deepEqual(done.dukeVidal, bad.dukeVidal);
assert.deepEqual(done.ecazAmbassadors, bad.ecazAmbassadors);
assert.equal(done.pendingAmbassador, null);
}
});
void test('Ecaz trigger never replenishes or shuffles a completed random cohort and does not disclose private hands', () => {
const initial = fixture();
for (const token of initial.ecazAmbassadors!.tokens)
if (initial.ecazAmbassadors!.cohort.includes(token.id))
token.zone = token.effect === 'beneGesserit' ? 'removed' : 'used';
const g = enter(initial),
cohort = structuredClone(g.ecazAmbassadors!.cohort),
before = reload(g);
const perturbed = reload(g);
p(perturbed, 'in').hand = perturbed.deck.splice(0, 3);
p(perturbed, 'al').traitors = ['emperor-1'];
assert.deepEqual(
viewGame(g, 'ec').ambassadorEntry,
viewGame(perturbed, 'ec').ambassadorEntry,
);
for (const id of ['in', 'al'])
assert.deepEqual(
viewGame(g, id).ambassadorEntry,
viewGame(perturbed, id).ambassadorEntry,
);
const done = acquire(g);
assert.deepEqual(g, before);
assert.deepEqual(done.ecazAmbassadors!.cohort, cohort);
assert.deepEqual(
done.ecazAmbassadors!.tokens.filter((t) => t.effect !== 'ecaz'),
g.ecazAmbassadors!.tokens.filter((t) => t.effect !== 'ecaz'),
);
assert.deepEqual(done.deck, g.deck);
assert.equal(
done.log.filter((l) => l.text.includes('drew a new supply')).length,
g.log.filter((l) => l.text.includes('drew a new supply')).length,
);
});
void test('actual Fremen worm ride interruption resumes the remaining ride once after Duke acquisition', () => {
let g = fixture('fremen', true);
Object.assign(g, {
phase: 1,
active: null,
nexus: false,
wormRides: ['hagga_basin'],
decision: { kind: 'wormRide', player: 'in', territory: 'imperial_basin' },
});
p(g, 'in').forces = { 'imperial_basin:10': 3, 'hagga_basin:11': 1 };
p(g, 'in').reserves = 16;
g = applyAction(g, 'in', {
type: 'decision',
accept: true,
territory: 'arrakeen',
sector: 10,
forces: { 'imperial_basin:10': 2 },
});
assert.equal(g.pendingAmbassador?.resume, 'wormRide');
const before = reload(g),
done = acquire(reload(g));
assert.equal(done.phase, 1);
assert.deepEqual(done.decision, {
kind: 'wormRide',
player: 'in',
territory: 'hagga_basin',
});
assert.deepEqual(done.players, before.players);
assert.deepEqual(done.wormRides, []);
assert.equal(done.dukeVidal!.controller, 'ec');
reject(done, 'ec', command(before));
});
function completeMovement(g: Game) {
for (let n = 0; g.phase === 5 && n < 10; n++) {
assert.ok(g.active);
g = applyAction(g, g.active, { type: 'endMovement' });
g = responses(g);
}
assert.equal(g.phase, 6);
return g;
}
function deadFromBattle() {
const initial = fixture();
p(initial, 'ec').forces = { 'arrakeen:10': 1 };
p(initial, 'ec').reserves = 19;
const at = initial.deck.findIndex((c) => c.kind === 'poison');
p(initial, 'in').hand = [initial.deck.splice(at, 1)[0]];
let g = completeMovement(acquire(enter(initial)));
g = applyAction(g, 'in', {
type: 'chooseBattle',
territory: 'arrakeen',
target: 'ec',
});
g = responses(g);
for (let n = 0; g.battle?.preparation && n < 8; n++) {
g = applyAction(g, g.battle.preparation.owner, {
type: 'declineBattlePower',
});
g = responses(g);
}
g = applyAction(g, 'in', {
type: 'battlePlan',
dial: 0,
leader: p(g, 'in').leaders.at(-1)!.id,
weapon: p(g, 'in').hand[0].id,
});
g = applyAction(g, 'ec', {
type: 'battlePlan',
dial: 0,
leader: DUKE_VIDAL_ID,
});
assert.equal(g.dukeVidal!.controller, 'ec');
g = applyAction(g, 'in', { type: 'traitorCall', call: false });
g = applyAction(g, 'ec', { type: 'traitorCall', call: false });
assert.equal(g.dukeVidal!.leader.dead, true);
assert.equal(g.dukeVidal!.leader.deaths, 1);
assert.equal(g.dukeVidal!.controller, null);
assert.equal(g.dukeVidal!.leader.id, DUKE_VIDAL_ID);
assert.equal(p(g, 'in').spice, 24);
assert.ok(p(g, 'ec').leaders.every((l) => !l.dead));
g = applyAction(g, 'in', { type: 'decision', discard: [] });
assert.equal(g.decision, null);
return g;
}
void test('an acquired Duke is usable in a genuine following battle, then the same disc is consumed after death', () => {
const g = deadFromBattle();
assert.equal(viewGame(g, 'ec').dukeVidal?.controller, null);
assert.ok(g.players.every((seat) => seat.leaders.every((l) => l.id !== DUKE_VIDAL_ID)));
});
void test('genuine acquisition and battle death lead to one paid Ecaz return of the shared disc at five, unclaimed', () => {
let g = deadFromBattle();
g.phase = 4;
g.turn = 3;
g.battle = null;
g.ready = [];
const ec = p(g, 'ec');
ec.spice = 9;
const option = viewGame(g, 'ec').revival.leaders.find((l) => l.id === DUKE_VIDAL_ID);
assert.deepEqual(option, {
id: DUKE_VIDAL_ID, name: 'Duke Prad Vidal',
normalCost: 5, cost: 5, early: false, affordable: true,
});
assert.equal(viewGame(g, 'in').revival.leaders.some((l) => l.id === DUKE_VIDAL_ID), false);
const before = structuredClone(g);
reject(g, 'in', { type: 'reviveLeader', leader: DUKE_VIDAL_ID });
assert.deepEqual(g, before);
g = applyAction(reload(g), 'ec', { type: 'reviveLeader', leader: DUKE_VIDAL_ID });
assert.equal(g.pendingRevival, null);
assert.equal(p(g, 'ec').spice, 4);
assert.equal(p(g, 'ec').leaderRevived, true);
assert.equal(g.dukeVidal!.leader.dead, false);
assert.equal(g.dukeVidal!.leader.deaths, 1);
assert.equal(g.dukeVidal!.leader.strength, 6);
assert.deepEqual(
[g.dukeVidal!.controller, g.dukeVidal!.source, g.dukeVidal!.acquiredTurn],
[null, null, null],
);
assert.equal(p(g, 'ec').leaders.length, 5);
assert.equal(viewGame(g, 'ec').players.find((seat) => seat.id === 'ec')!.leaders.some((l) => l.id === DUKE_VIDAL_ID), false);
assert.equal(viewGame(g, 'ec').revival.leaders.some((l) => l.id === DUKE_VIDAL_ID), false);
reject(g, 'ec', { type: 'reviveLeader', leader: DUKE_VIDAL_ID });
assert.deepEqual(reload(g).dukeVidal, g.dukeVidal);
});
void test('Ghola revives the genuine dead Ecaz Duke as an unclaimed disc without spice or ordinary slot', () => {
let g = deadFromBattle();
const cardIndex = g.deck.findIndex((c) => c.effect === 'ghola');
assert.ok(cardIndex >= 0);
const card = g.deck.splice(cardIndex, 1)[0];
p(g, 'ec').hand.push(card);
const beforeSpice = p(g, 'ec').spice;
assert.equal(viewGame(g, 'ec').ghola.leaders.some((l) => l.id === DUKE_VIDAL_ID), true);
assert.equal(viewGame(g, 'in').ghola.leaders.some((l) => l.id === DUKE_VIDAL_ID), false);
reject(g, 'in', { type: 'card', card: card.id, leader: DUKE_VIDAL_ID });
g = applyAction(reload(g), 'ec', { type: 'card', card: card.id, leader: DUKE_VIDAL_ID });
assert.equal(p(g, 'ec').spice, beforeSpice);
assert.equal(p(g, 'ec').leaderRevived, false);
assert.equal(g.dukeVidal!.leader.dead, false);
assert.equal(g.dukeVidal!.leader.deaths, 1);
assert.equal(g.dukeVidal!.controller, null);
assert.equal(g.dukeVidal!.source, null);
assert.equal(g.dukeVidal!.leader.usedAt, undefined);
assert.equal(g.dukeVidal!.acquiredTurn, null);
assert.equal(p(g, 'ec').leaders.length, 5);
assert.equal(p(g, 'ec').hand.some((c) => c.id === card.id), false);
assert.deepEqual(reload(g).dukeVidal, g.dukeVidal);
assert.equal(viewGame(reload(g), 'ec').ghola.leaders.some((l) => l.id === DUKE_VIDAL_ID), false);
});
void test('unused source-Ecaz custody persists through a genuine end of turn without manufacturing another disc', () => {
let g = acquire(enter());
const original = structuredClone(g.dukeVidal!);
for (let n = 0; g.turn === 2 && n < 40; n++) {
g = responses(g);
if (g.phase === 5) {
assert.ok(g.active);
g = applyAction(g, g.active, { type: 'endMovement' });
} else if (g.decision?.kind === 'ecazPlacement')
g = applyAction(g, g.decision.player, {
type: 'decision',
decline: true,
});
else if (g.decision?.kind === 'choamMarket')
g = applyAction(g, g.decision.player, { type: 'decision', done: true });
else {
const next = g.players.find((p) => !g.ready.includes(p.id));
assert.ok(next);
g = applyAction(g, next.id, { type: 'ready' });
}
}
assert.equal(g.turn, 3);
assert.deepEqual(g.dukeVidal, original);
assert.ok(
g.players.every((p) => !p.leaders.some((l) => l.id === DUKE_VIDAL_ID)),
);
});
void test('concealed custody always projects one unavailable descriptor regardless of private live fields', () => {
const entered = enter();
entered.dukeVidal!.leader.concealed = {
captor: 'in',
controller: 'ec',
dead: false,
deaths: 0,
};
const descriptors: ({ blocked: string | null } | null)[] = [];
for (const captured of [false, true])
for (const dead of [false, true]) {
const g = reload(entered);
g.dukeVidal!.leader.dead = dead;
if (captured) g.dukeVidal!.leader.capturedBy = 'in';
else delete g.dukeVidal!.leader.capturedBy;
descriptors.push(viewGame(g, 'ec').ambassadorEntry!.dukeAcquisition);
assert.equal(
ecazDukeAcquisitionBlock(g, 'ec'),
'Duke Vidal is unavailable for this acquisition.',
);
reject(g, 'ec', command(g));
}
assert.ok(
descriptors.every(
(value) => JSON.stringify(value) === JSON.stringify(descriptors[0]),
),
);
assert.deepEqual(descriptors[0], {
blocked: 'Duke Vidal is unavailable for this acquisition.',
});
});
void test('duplicated shared Duke in a native roster is uniformly unavailable and cannot commit the token', () => {
const g = enter();
p(g, 'al').leaders.push(structuredClone(g.dukeVidal!.leader));
assert.deepEqual(viewGame(g, 'ec').ambassadorEntry!.dukeAcquisition, {
blocked: 'Duke Vidal is unavailable for this acquisition.',
});
reject(g, 'ec', command(g));
});
void test('later qualifying Moritani acquisition takes the same Ecaz-held disc through actual end-of-movement', () => {
const initial = fixture('moritani');
for (const id of ['in', 'al']) {
p(initial, id).forces = { 'carthag:11': 2, 'sietch_tabr:14': 2 };
p(initial, id).reserves = 16;
}
const gained = acquire(enter(initial));
assert.equal(gained.dukeVidal!.source, 'ecaz');
const done = completeMovement(gained);
assert.equal(done.dukeVidal!.controller, 'in');
assert.equal(done.dukeVidal!.source, 'moritani');
assert.equal(done.dukeVidal!.leader.id, DUKE_VIDAL_ID);
assert.equal(done.dukeVidal!.leader.deaths, 0);
assert.equal(done.dukeAcquisitionTurn, 2);
assert.equal(done.phase, 6);
});