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(); 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); });