import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, viewGame, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { acquireDuke, consumeDuke, createDukeVidal, expireDuke } from '../game/duke-vidal'; import { createNexusCards, discardNexusCard } from '../game/nexus-cards'; import { createNexusEcazDukeReceipt, nexusEcazDukeEvent, nexusEcazDukeSignature, quoteNexusEcazDuke, validateNexusEcazDuke, type NexusEcazDukeReceipt, } from '../game/nexus-ecaz-duke'; import { nexusEcazDukePosition } from './fixture-nexus-ecaz-duke'; import { nexusTraitorInventory } from './fixture-nexus-traitors'; function game(advanced = false): Game { const players = [ { id: 'ecaz-seat', faction: 'ecaz' as const, ally: null }, { id: 'moritani-seat', faction: 'moritani' as const, ally: null }, { id: 'atreides-seat', faction: 'atreides' as const, ally: null }, ]; const cards = createNexusCards(players, () => 0.5); cards.deck.splice(cards.deck.indexOf('ecaz'), 1); cards.hands['ecaz-seat'] = 'ecaz'; return { players, advanced, expansions: [], nexusCards: { cards, phase: null }, status: 'playing', phase: 6, turn: 3, dukeVidal: createDukeVidal(), } as unknown as Game; } void test('Ecaz Cunning validates an ordinary Ambassador acquisition held by another seated recipient', () => { // Controlled prior custody: ordinary sharing can outlast the earlier alliance. const g = game(); g.dukeVidal = acquireDuke(g.dukeVidal!, 'atreides-seat', 2, 'ecaz'); assert.equal(quoteNexusEcazDuke(g, 'ecaz-seat')!.blocked, null); const record = createNexusEcazDukeReceipt(g, 'ecaz-seat'); assert.equal(record.before.controller, 'atreides-seat'); assert.equal(record.before.source, 'ecaz'); g.dukeVidal = acquireDuke(g.dukeVidal!, 'ecaz-seat', g.turn, 'ecazNexus'); g.nexusCards!.cards = discardNexusCard(g.nexusCards!.cards!, 'ecaz-seat', g.players); g.nexusEcazDukeHistory = [record]; g.nexusEcazDukeEvents = [record.event]; assert.doesNotThrow(() => validateNexusEcazDuke(g, record, 0)); }); void test('native physical Ecaz Cunning overrides Moritani at a quiet Battle boundary', () => { const g = game(true); g.dukeVidal = acquireDuke(g.dukeVidal!, 'moritani-seat', 3, 'moritani'); const offer = quoteNexusEcazDuke(g, 'ecaz-seat'); assert.deepEqual(offer, { event: nexusEcazDukeEvent(3, 6, 'ecaz-seat', 0, 'moritani-seat', 3), blocked: null, dukeController: 'moritani-seat', }); assert.equal(quoteNexusEcazDuke(g, 'moritani-seat'), null); const record = createNexusEcazDukeReceipt(g, 'ecaz-seat'); assert.equal(record.before.source, 'moritani'); assert.deepEqual(record.after, { controller: 'ecaz-seat', acquiredTurn: 3, source: 'ecazNexus', }); g.dukeVidal = acquireDuke(g.dukeVidal!, 'ecaz-seat', g.turn, 'ecazNexus'); g.nexusCards!.cards = discardNexusCard(g.nexusCards!.cards!, 'ecaz-seat', g.players); g.nexusEcazDukeHistory = [record]; g.nexusEcazDukeEvents = [record.event]; assert.equal(g.nexusCards!.cards!.hands['ecaz-seat'], null); assert.deepEqual(JSON.parse(JSON.stringify(record)), record); assert.equal(nexusEcazDukeSignature(JSON.parse(JSON.stringify(record))), record.signature); // Completed history does not need the original custody or the spent card to remain current. g.dukeVidal = expireDuke(g.dukeVidal!, 3); assert.equal(g.dukeVidal.controller, null); assert.equal(g.dukeVidal.source, null); g.turn = 4; g.phase = 1; }); void test('unclaimed Duke creates a null-safe public event and historical receipt', () => { const g = game(); const offer = quoteNexusEcazDuke(g, 'ecaz-seat'); assert.deepEqual(offer, { event: nexusEcazDukeEvent(3, 6, 'ecaz-seat', 0, null, null), blocked: null, dukeController: null, }); const receipt = createNexusEcazDukeReceipt(g, 'ecaz-seat'); assert.deepEqual(receipt.before, { controller: null, acquiredTurn: null, source: null, leader: { id: 'duke-vidal', dead: false, capturedBy: null, gholaBy: null }, }); assert.equal(receipt.signature, nexusEcazDukeSignature(JSON.parse(JSON.stringify(receipt)))); }); void test('Ecaz Nexus alone revives the set-aside shared Duke and clears only his battle-use stamp', () => { for (const advanced of [false, true]) { const g = game(advanced); const tanked = g.dukeVidal!; tanked.leader.dead = true; tanked.leader.deaths = 3; tanked.leader.usedAt = 'carthag'; const before = structuredClone(tanked); assert.deepEqual(quoteNexusEcazDuke(g, 'ecaz-seat'), { event: nexusEcazDukeEvent(3, 6, 'ecaz-seat', 0, null, null), blocked: null, dukeController: null, }); for (const source of ['moritani', 'ecaz', 'ally'] as const) assert.throws(() => acquireDuke(tanked, 'ecaz-seat', 3, source), /Tanks/); const receipt = createNexusEcazDukeReceipt(g, 'ecaz-seat'); assert.deepEqual(receipt.before, { controller: null, acquiredTurn: null, source: null, leader: { id: 'duke-vidal', dead: true, capturedBy: null, gholaBy: null }, }); assert.deepEqual(receipt.after, { controller: 'ecaz-seat', acquiredTurn: 3, source: 'ecazNexus', }); const acquired = acquireDuke(tanked, 'ecaz-seat', 3, 'ecazNexus'); const expectedLeader = { ...before.leader, dead: false }; delete expectedLeader.usedAt; assert.deepEqual(acquired.leader, expectedLeader); assert.deepEqual(tanked, before); assert.notEqual(acquired.leader, tanked.leader); assert.deepEqual([acquired.controller, acquired.acquiredTurn, acquired.source], ['ecaz-seat', 3, 'ecazNexus']); g.dukeVidal = expireDuke(acquired, 3); g.turn = 4; g.phase = 1; validateNexusEcazDuke(g, JSON.parse(JSON.stringify(receipt)), 0); assert.equal(g.dukeVidal.controller, null); assert.equal(g.dukeVidal.leader.dead, false); assert.equal(g.dukeVidal.leader.deaths, 3); assert.equal(receipt.signature, nexusEcazDukeSignature(receipt)); } }); void test('native Basic and Advanced bots can spend actual Ecaz Nexus on a Tanked Duke', () => { for (const advanced of [false, true]) { for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) { const { game: g } = nexusEcazDukePosition(advanced); // Explicit conserved Tanks position after genuine setup and physical Nexus draw. g.dukeVidal = consumeDuke(g.dukeVidal!); g.dukeVidal.leader.dead = true; g.dukeVidal.leader.deaths = 2; g.dukeVidal.leader.usedAt = 'carthag'; g.players[0].bot = profile; const before = structuredClone(g); const view = viewGame(g, 'p'); assert.equal(view.nexusEcazDuke?.blocked, null); assert.equal(viewGame(g, 'q').nexusEcazDuke, null); const action = botActions(view)[0]; assert.deepEqual(action, { type: 'nexusEcazDuke', event: view.nexusEcazDuke!.event }); const result = applyAction(g, 'p', action); assert.deepEqual(g, before); assert.equal(result.nexusCards!.cards!.hands.p, null); assert.equal(result.nexusCards!.cards!.discard.filter(card => card === 'ecaz').length, 1); assert.deepEqual([result.dukeVidal!.controller, result.dukeVidal!.source, result.dukeVidal!.acquiredTurn], ['p', 'ecazNexus', result.turn]); assert.equal(result.dukeVidal!.leader.id, before.dukeVidal!.leader.id); assert.equal(result.dukeVidal!.leader.dead, false); assert.equal(result.dukeVidal!.leader.deaths, 2); assert.equal(result.dukeVidal!.leader.usedAt, undefined); assert.equal(result.nexusEcazDukeHistory![0].before.leader.dead, true); assert.deepEqual(result.nexusEcazDukeEvents, [action.event]); assert.equal(result.players.flatMap(player => player.leaders) .some(leader => leader.id === 'duke-vidal'), false); assert.equal(viewGame(result, 'p').players.find(player => player.id === 'p')!.leaders .some(leader => leader.id === 'duke-vidal' && !leader.dead && !leader.usedAt), true); assert.deepEqual(result.players.map(player => [player.spice, player.reserves, player.leaders]), before.players.map(player => [player.spice, player.reserves, player.leaders])); nexusTraitorInventory(result); assert.throws(() => applyAction(result, 'p', action)); } } }); void test('living already-used Duke cannot gain a new battle use from Ecaz Cunning', () => { const g = game(); g.dukeVidal!.leader.usedAt = 'carthag'; g.dukeVidal!.leader.deaths = 2; const before = structuredClone(g.dukeVidal); assert.throws(() => createNexusEcazDukeReceipt(g, 'ecaz-seat')); assert.throws(() => acquireDuke(g.dukeVidal!, 'ecaz-seat', 3, 'ecazNexus')); assert.deepEqual(g.dukeVidal, before); const ordinary = acquireDuke(g.dukeVidal!, 'moritani-seat', 3, 'moritani'); assert.deepEqual(ordinary.leader, before!.leader); }); void test('Tanked acquisition requires set-aside custody and never bypasses capture or Ghola', () => { const g = game(); g.dukeVidal!.leader.dead = true; g.dukeVidal!.leader.deaths = 1; for (const change of [ { controller: 'moritani-seat' }, { acquiredTurn: 2 }, { source: 'moritani' as const }, ]) { const duke = { ...g.dukeVidal!, ...change }; const before = structuredClone(duke); assert.throws(() => acquireDuke(duke, 'ecaz-seat', 3, 'ecazNexus')); assert.deepEqual(duke, before); } for (const dead of [false, true]) { for (const custody of ['capturedBy', 'gholaBy'] as const) { const duke = structuredClone(g.dukeVidal!); duke.leader.dead = dead; duke.leader[custody] = 'moritani-seat'; const before = structuredClone(duke); for (const source of ['moritani', 'ecaz', 'ecazNexus', 'ally'] as const) assert.throws(() => acquireDuke(duke, 'ecaz-seat', 3, source)); assert.deepEqual(duke, before); } } }); void test('dead receipts reject non-set-aside Tanks history', () => { const g = game(); g.dukeVidal!.leader.dead = true; g.dukeVidal!.leader.deaths = 2; const original = createNexusEcazDukeReceipt(g, 'ecaz-seat'); validateNexusEcazDuke(g, original, 0); for (const change of [ { controller: 'moritani-seat', acquiredTurn: 3, source: 'moritani' as const }, { acquiredTurn: 2 }, { source: 'ecaz' as const }, ]) { const damaged = structuredClone(original); Object.assign(damaged.before, change); damaged.event = nexusEcazDukeEvent(damaged.turn, damaged.phase, damaged.owner, damaged.sequence, damaged.before.controller, damaged.before.acquiredTurn); damaged.signature = nexusEcazDukeSignature(damaged); assert.throws(() => validateNexusEcazDuke(g, damaged, 0)); } const damaged = structuredClone(original); damaged.before.leader.dead = 'true' as never; damaged.signature = nexusEcazDukeSignature(damaged); assert.throws(() => validateNexusEcazDuke(g, damaged, 0)); }); void test('ordinary Ecaz Ambassador custody survives end turn; Nexus custody does not', () => { const g = game(); const ordinary = acquireDuke(g.dukeVidal!, 'ecaz-seat', 3, 'ecaz'); assert.equal(expireDuke(ordinary, 3).controller, 'ecaz-seat'); assert.equal(expireDuke(ordinary, 4).source, 'ecaz'); const moritani = acquireDuke(ordinary, 'moritani-seat', 3, 'moritani'); const nexus = acquireDuke(moritani, 'ecaz-seat', 3, 'ecazNexus'); assert.equal(expireDuke(nexus, 2).source, 'ecazNexus'); assert.equal(expireDuke(nexus, 3).controller, null); for (const property of ['capturedBy', 'gholaBy'] as const) { const unavailable = structuredClone(nexus); unavailable.leader[property] = 'moritani-seat'; assert.throws(() => acquireDuke(unavailable, 'ecaz-seat', 3, 'ecazNexus')); } }); void test('damaged and re-signed impossible history cannot forge the original Duke or seated mode', () => { const g = game(); g.dukeVidal = acquireDuke(g.dukeVidal!, 'moritani-seat', 3, 'moritani'); const original = createNexusEcazDukeReceipt(g, 'ecaz-seat'); const invalid = (change: (record: NexusEcazDukeReceipt) => void) => { const record = structuredClone(original); change(record); assert.throws(() => validateNexusEcazDuke(g, record, 0)); record.signature = nexusEcazDukeSignature(record); assert.throws(() => validateNexusEcazDuke(g, record, 0)); }; invalid(record => { record.before.leader.dead = true; }); invalid(record => { record.before.leader.capturedBy = 'moritani-seat' as never; }); invalid(record => { record.before.leader.gholaBy = 'ecaz-seat' as never; }); invalid(record => { record.before.controller = 'outsider'; }); invalid(record => { record.before.acquiredTurn = 4; }); invalid(record => { record.before.acquiredTurn = 2; }); invalid(record => { record.before.source = null; }); // Ordinary Ecaz tenure may now belong to a shared recipient; Nexus tenure // still cannot be forged for a non-Ecaz controller. invalid(record => { record.before.source = 'ecazNexus'; }); invalid(record => { record.before.acquiredTurn = 2; record.event = nexusEcazDukeEvent(3, 6, 'ecaz-seat', 0, 'moritani-seat', 2); }); invalid(record => { record.after.controller = 'moritani-seat'; }); invalid(record => { record.after.source = 'ecaz' as never; }); invalid(record => { record.after.acquiredTurn = 2; }); invalid(record => { record.owner = 'moritani-seat'; }); invalid(record => { record.mode = 'betrayal' as never; }); invalid(record => { record.roster = ['ecaz', 'ecaz']; }); invalid(record => { record.advanced = true; }); invalid(record => { record.turn = 4; }); invalid(record => { record.phase = 7; }); invalid(record => { record.sequence = 1; }); invalid(record => { record.event = nexusEcazDukeEvent(3, 6, 'ecaz-seat', 0, null, null); }); invalid(record => { (record as NexusEcazDukeReceipt & { extra: boolean }).extra = true; }); invalid(record => { record.before.leader.id = 'other' as never; }); assert.throws(() => validateNexusEcazDuke(g, null as never, 0)); assert.throws(() => validateNexusEcazDuke({ ...g, phase: 5 }, original, 0)); assert.throws(() => validateNexusEcazDuke({ ...g, nexusCards: null }, original, 0)); }); void test('legacy Battle-only records cannot be re-signed as earlier-phase Cunning', () => { const g = game(); const fields = structuredClone(createNexusEcazDukeReceipt(g, 'ecaz-seat')) as unknown as Record; delete fields.version; 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)); });