import test from 'node:test'; import assert from 'node:assert/strict'; import { createGame, joinGame, newPlayer, type Game, type Player, } from '../game/engine'; import { createNexusCards, discardNexusCard, drawNexusCard, } from '../game/nexus-cards'; import { createTechTokens } from '../game/tech-tokens'; import { createEmperorNexusRevival, emperorNexusEvent, emperorNexusSignature, settleEmperorNexusRevival, type NexusEmperorRevival, type NexusEmperorRevivalRequest, } from '../game/nexus-emperor-secret-ally'; import { newRevivalRules, type PendingRevival } from '../game/revival'; import { quoteRevivalResume, RevivalResumeError } from '../game/revival-resume'; import { continuationCustody, quoteRevivalCancellation, RevivalCancellationError, } from '../game/revival-cancellation'; import { createHomeworldCustody, type HomeworldCustody, } from '../game/homeworld-custody'; import { homeworldContext } from '../game/homeworld-game'; import { quoteHomeworldRevivalDeployment, type HomeworldRevivalDeploymentGroup, type HomeworldRevivalDeploymentSource, } from '../game/homeworld-revival-deployment'; import { makeHomeworldRevivalProgress, makeHomeworldRevivalReturn, validateHomeworldRevivalReturn, } from '../game/homeworld-revival-return'; const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!; const json = (value: T): T => JSON.parse(JSON.stringify(value)); /** Resolved-facts unit context, not a native gameplay chronology. Nexus custody * still uses the original inventory/draw/discard APIs and the real source receipt. */ function fixture( options: { advanced?: boolean; tleilaxu?: boolean; consumed?: boolean; declared?: boolean; ordinaryUsed?: number } = {}, ) { const advanced = options.advanced ?? false; const g = createGame( 'NEXUSEMPEROREXTRA', newPlayer('p', 'Fremen', 'fremen'), advanced, options.tleilaxu ? ['ix'] : [], ); joinGame(g, newPlayer('a', 'Atreides', 'atreides')); joinGame(g, newPlayer('h', 'Harkonnen', 'harkonnen')); if (options.tleilaxu) joinGame(g, newPlayer('t', 'Tleilaxu', 'tleilaxu')); Object.assign(g, { status: 'playing', phase: 4, turn: 2, order: g.players.map((p) => p.id), revivalRules: newRevivalRules(), freeRevival: [], }); for (const p of g.players) Object.assign(p, { hand: [], spice: 40, tanks: 10, reserves: 10, forces: {} }); player(g, 'p').revived = options.ordinaryUsed ?? 0; player(g, 'p').freeForcesRevived = options.ordinaryUsed ?? 0; let cards = createNexusCards(g.players, () => 0.5); cards.deck = ['emperor', ...cards.deck.filter((card) => card !== 'emperor')]; cards = drawNexusCard(cards, 'p', g.players, () => 0); g.nexusCards = { cards, phase: null }; const receipt = createEmperorNexusRevival(g, 'p', 0); if (options.declared !== false) { g.nexusEmperorSecretHistory = [receipt]; g.nexusEmperorSecretEvents = [receipt.event]; if (options.consumed !== false) g.nexusCards.cards = discardNexusCard(g.nexusCards.cards!, 'p', g.players); } return { g, receipt }; } function pending(receipt: NexusEmperorRevivalRequest): PendingRevival { return { player: 'p', kind: 'forces', amount: 3, elite: receipt.elite, free: 3, cost: 0, normalCost: 0, checks: [], nexusEmperorEvent: receipt.event, }; } void test('the requested source quotes the original stop and finish continuations without repricing', () => { const advanced = fixture({ advanced: true, tleilaxu: true }); advanced.g.techTokens = createTechTokens(advanced.g.players); const stop = json(advanced.g); const stopQuote = quoteRevivalResume( { ...advanced.g, pendingRevival: pending(advanced.receipt) }, 'stop', ); assert.equal(stopQuote.outcome, 'decision'); assert.deepEqual(json(advanced.g), stop, 'quoting changes nothing'); const finishQuote = quoteRevivalResume( { ...advanced.g, pendingRevival: pending(advanced.receipt) }, 'finish', ); assert.equal(finishQuote.outcome, 'revive'); assert.deepEqual(finishQuote.freeIncome, { player: 'p', turn: 2 }); assert.deepEqual(finishQuote.techIncome, { id: 'axlotl', owner: 't', spice: 1, triggeredTurn: 2, }); assert.deepEqual(finishQuote.nextResponse, { kind: 'revivalIncome', owner: 't', recipient: 'p', amount: 1, passed: [], }); // The fixed grant is exactly three free counters at no ordinary cost. assert.equal(finishQuote.pending.amount, 3); assert.equal(finishQuote.pending.free, 3); assert.equal(finishQuote.pending.cost, 0); assert.equal(finishQuote.pending.normalCost, 0); }); void test('ordinary caps, blocks and Recruits state never block or reprice the independent three', () => { const { g, receipt } = fixture({ ordinaryUsed: 20 }); const state = structuredClone({ p: player(g, 'p'), spice: player(g, 'p').spice, }); Object.assign(g, { revivalRules: { ...newRevivalRules(), limitBlocked: true, freeBlocked: ['p'], }, freeRevival: ['p'], recruits: { turn: g.turn, player: 'p', card: 'ecaz-recruits' }, emperorExtra: { p: 0 }, }); const before = json(g); const quote = quoteRevivalResume({ ...g, pendingRevival: pending(receipt) }, 'finish'); assert.equal(quote.outcome, 'revive'); assert.deepEqual(json(g), before, 'no counter, wallet or rule state moves'); assert.equal(player(g, 'p').revived, 20); assert.equal(player(g, 'p').freeForcesRevived, 20); assert.equal(g.emperorExtra!.p, 0, 'the native Emperor-extra allowance is untouched'); assert.equal(player(g, 'p').spice, state.spice, 'no ordinary price is charged'); assert.equal(player(g, 'p').tanks, 10, 'the physical Tanks move only on the real return'); continuationCustody({ ...g, pendingRevival: null }, player(g, 'p'), pending(receipt)); }); void test('native physical and typed caps still apply to the extra return', () => { const { g, receipt } = fixture(); const p = player(g, 'p'); p.elites = { reserves: 5, tanks: 2, forces: {}, revived: 0 }; p.tanks = 4; const extra = { ...pending(receipt), elite: 3 }; assert.throws( () => continuationCustody({ ...g, pendingRevival: null }, p, extra), /ordinary and elite revival groups/, ); p.tanks = 2; assert.throws( () => continuationCustody({ ...g, pendingRevival: null }, p, pending(receipt)), /no longer in the tanks/, ); }); void test('malformed, stale, foreign, missing, unconsumed and outside-request sources are rejected immutably', () => { const base = fixture(); const state = json(base.g); const cases: ((g: Game, receipt: NexusEmperorRevivalRequest) => PendingRevival)[] = [ (g, receipt) => ({ ...pending(receipt), amount: 2 }), (g, receipt) => ({ ...pending(receipt), free: 2 }), (g, receipt) => ({ ...pending(receipt), elite: 1 }), (g, receipt) => ({ ...pending(receipt), cost: 1 }), (g, receipt) => ({ ...pending(receipt), player: 'a' }), (g, receipt) => ({ ...pending(receipt), payer: 'a' }), (g, receipt) => ({ ...pending(receipt), emperorExtra: true }), (g, receipt) => ({ ...pending(receipt), nexusEmperorEvent: receipt.event + 'x' }), (g) => { g.nexusEmperorSecretHistory = undefined; g.nexusEmperorSecretEvents = undefined; return pending(base.receipt); }, (g) => { g.nexusEmperorSecretHistory![0].signature += 'x'; return pending(base.receipt); }, (g) => { g.nexusEmperorSecretHistory![0].sequence = 1; return pending(base.receipt); }, (g) => { g.nexusEmperorSecretHistory![0].elite = 1; return pending(base.receipt); }, (g) => { player(g, 'a').ally = 'p'; player(g, 'p').ally = 'a'; return pending(base.receipt); }, (g) => { player(g, 'p').tanks = 9; return pending(base.receipt); }, (g) => { player(g, 'h').faction = 'emperor'; return pending(base.receipt); }, ]; for (const build of cases) { const g = json(base.g); const request = build(g, json(base.receipt)); const snapshot = json(g); assert.throws( () => quoteRevivalResume({ ...g, pendingRevival: request }, 'finish'), RevivalResumeError, ); assert.deepEqual(json(g), snapshot, 'a rejected source mutates nothing'); } const unconsumed = fixture({ consumed: false }); assert.throws( () => quoteRevivalResume( { ...unconsumed.g, pendingRevival: pending(unconsumed.receipt) }, 'finish', ), /consumed physical card/, ); const stale: NexusEmperorRevivalRequest = { ...json(base.receipt), turn: 1, event: emperorNexusEvent(1, 4, 'p', 0), signature: '', }; stale.signature = emperorNexusSignature(stale); const staleGame = json(base.g); staleGame.nexusEmperorSecretHistory = [stale]; staleGame.nexusEmperorSecretEvents = [stale.event]; assert.throws( () => quoteRevivalResume( { ...staleGame, pendingRevival: pending(stale) }, 'finish', ), RevivalResumeError, ); assert.deepEqual(json(base.g), state, 'the fixture survives every rejected read'); }); void test('a settled returned or denied source no longer authorizes another pending revival', () => { const returned = fixture(); const p = player(returned.g, 'p'); p.tanks -= 3; p.reserves += 3; returned.g.nexusEmperorSecretHistory![0] = settleEmperorNexusRevival( returned.g, json(returned.g.nexusEmperorSecretHistory![0]), 'returned', ); assert.throws( () => quoteRevivalResume( { ...returned.g, pendingRevival: pending(returned.receipt) }, 'finish', ), RevivalResumeError, ); const denied = fixture(); denied.g.nexusEmperorSecretHistory![0] = settleEmperorNexusRevival( denied.g, json(denied.g.nexusEmperorSecretHistory![0]), 'denied', ); assert.throws( () => quoteRevivalResume( { ...denied.g, pendingRevival: pending(denied.receipt) }, 'finish', ), RevivalResumeError, ); }); void test('ordinary benefit cancellations do not reach the independent extra source', () => { const { g, receipt } = fixture({ advanced: true, tleilaxu: true }); for (const kind of ['revivalLimit', 'revivalDiscount'] as const) { const request = pending(receipt); const snapshot = json(g); assert.throws( () => quoteRevivalCancellation( { ...g, pendingRevival: request }, { kind, owner: 't', recipient: 'p' }, ), RevivalCancellationError, ); assert.deepEqual(json(g), snapshot); } }); void test('ordinary free and paid requests keep their original continuation', () => { const { g } = fixture({ declared: false }); const ordinary: PendingRevival = { player: 'p', kind: 'forces', amount: 3, elite: 0, free: 3, cost: 0, normalCost: 0, checks: [], }; assert.equal( quoteRevivalResume({ ...g, pendingRevival: ordinary }, 'finish').outcome, 'revive', ); player(g, 'p').tanks = 2; assert.equal( quoteRevivalResume({ ...g, pendingRevival: ordinary }, 'finish').outcome, 'fremenLimit', ); }); type HomeworldFixture = { advanced: boolean; players: Player[]; homeworlds: { custody: HomeworldCustody }; }; function homeworldFixture( faction: 'fremen' | 'tleilaxu', reserves: number, ): HomeworldFixture { const players = [ newPlayer('p', 'Native', faction), newPlayer('v', 'Visitor', 'harkonnen'), ]; players[0].reserves = reserves; if (faction === 'fremen') players[0].elites = { reserves: 0, tanks: 3, forces: {}, revived: 0 }; const context = { advanced: true, players }; return { ...context, homeworlds: { custody: createHomeworldCustody(homeworldContext(context)) }, }; } function deposited( before: HomeworldFixture, group: HomeworldRevivalDeploymentGroup, ) { const after = structuredClone(before); after.players[0].reserves += group.amount; if (after.players[0].elites) after.players[0].elites.reserves += group.elite; if (after.homeworlds.custody.salusa) after.homeworlds.custody.salusa.elite += group.elite; return after; } void test('the typed Homeworld source accepts the canonical exact-three free group for Fremen', () => { const sources: HomeworldRevivalDeploymentSource[] = [ 'normal', 'emperorExtra', 'ghola', 'nexusTleilaxu', 'nexusEmperor', ]; for (const source of sources) { const before = homeworldFixture('fremen', 3); before.players[0].elites!.reserves = 1; const group = { amount: 3, elite: 2, free: source === 'normal' || source === 'nexusEmperor' ? 3 : 0, }; const after = deposited(before, group); assert.deepEqual( quoteHomeworldRevivalDeployment(before, after, 'p', source, group), { kind: 'fedaykin', normal: 0, elite: 2, beforePopulation: 3, afterPopulation: 6, blocked: null, }, source, ); } // Tleilax free-revival deployment is still ordinary Free Revival only: the // independent zero-spice source never fabricates a Tleilaxu free return. for (const source of ['nexusEmperor', 'emperorExtra', 'ghola'] as const) { const before = homeworldFixture('tleilaxu', 9); const group = { amount: 3, elite: 0, free: source === 'nexusEmperor' ? 3 : 0, }; assert.equal( quoteHomeworldRevivalDeployment( before, deposited(before, group), 'p', source, group, ), null, source, ); } }); void test('the Homeworld source rejects any non-canonical group and a mismatched frame immutably', () => { const before = homeworldFixture('fremen', 3); before.players[0].elites!.reserves = 1; const group = { amount: 3, elite: 2, free: 3 }; const after = deposited(before, group); const snapshot = structuredClone({ before, after }); for (const bad of [ { ...group, free: 2 }, { ...group, free: 0 }, { ...group, amount: 2 }, ]) { assert.throws(() => quoteHomeworldRevivalDeployment( before, deposited(before, bad), 'p', 'nexusEmperor', bad, ), ); } assert.deepEqual({ before, after }, snapshot); const quote = quoteHomeworldRevivalDeployment( before, after, 'p', 'nexusEmperor', group, )!; const original = { event: 'nx-emperor-return', turn: 2, phase: 4, player: 'p', source: 'nexusEmperor' as const, group, quote, }; const frame = makeHomeworldRevivalReturn(original); const g = { ...after, turn: 2, phase: 4, homeworldRevivalProgress: makeHomeworldRevivalProgress(frame), }; validateHomeworldRevivalReturn(g, frame); validateHomeworldRevivalReturn(g, json(frame)); for (const bad of [ { ...group, free: 2 }, { ...group, amount: 4 }, ]) { const tampered = { ...json(frame), group: bad } as typeof frame; assert.throws(() => validateHomeworldRevivalReturn(g, tampered)); } assert.throws(() => makeHomeworldRevivalReturn({ ...original, group: { amount: 3, elite: 0, free: 2 }, }), ); }); void test('the stored extra receipt round-trips as strict requested evidence', () => { const { g, receipt } = fixture(); const copy = json(receipt) as NexusEmperorRevival; assert.deepEqual(copy, receipt); assert.equal(copy.version, 2); assert.equal(copy.stage, 'requested'); assert.equal(copy.after, null); assert.equal(copy.signature, emperorNexusSignature(copy)); assert.equal(g.nexusEmperorSecretEvents![0], receipt.event); });