import type { Game, Player } from './engine'; import { eliteRevivalRemaining, revivalPrevented } from './revival'; import { FACTIONS } from './catalog'; import { nexusCoreProfile } from './faction-module-profile'; import { classicNexusModulesProfile, pairedNexusModulesProfile, singleE1E2NexusProfile, mixedE1E2NexusProfile, standaloneE3NexusProfile, pairedE3NexusProfile, mixedE3NexusProfile, } from './nexus-module-profile'; import { classicNexusLeaderSkillsProfile, pairedNexusLeaderSkillsProfile, singleE1E2NexusLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, pairedE3NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile, } from './leader-skill-profile'; export const EMPEROR_NEXUS_REVIVALS = 3; export type EmperorNexusPools = { reserves: number; tanks: number; eliteReserves: number; eliteTanks: number; eliteRevived: number; revived: number; freeForcesRevived: number | null; spice: number; }; type EmperorNexusRevivalIdentity = { kind: 'revival'; event: string; owner: string; turn: number; phase: 4; sequence: number; faction: Player['faction']; advanced: boolean; elite: number; before: EmperorNexusPools; signature: string; }; /** Old receipts are completed physical evidence, not permission to return again. */ export type NexusEmperorRevival = | (EmperorNexusRevivalIdentity & { version?: never; stage?: never; after: EmperorNexusPools; }) | (EmperorNexusRevivalIdentity & { version: 2; amount?: 1 | 2 } & ( | { stage: 'requested'; after: null } | { stage: 'returned' | 'denied'; after: EmperorNexusPools } )); export type NexusEmperorRevivalRequest = Extract< NexusEmperorRevival, { version: 2; stage: 'requested' } >; /** Existing receipts omit the amount and retain their original three-force * signature. Only a new short requested return records one or two. */ export function emperorNexusRevivalCount(receipt: NexusEmperorRevival): number { return receipt.version === 2 ? receipt.amount ?? EMPEROR_NEXUS_REVIVALS : EMPEROR_NEXUS_REVIVALS; } export function emperorNexusPools(p: Player): EmperorNexusPools { return { reserves: p.reserves, tanks: p.tanks, eliteReserves: p.elites?.reserves ?? 0, eliteTanks: p.elites?.tanks ?? 0, eliteRevived: p.elites?.revived ?? 0, revived: p.revived, freeForcesRevived: p.freeForcesRevived ?? null, spice: p.spice, }; } export function emperorNexusEvent( turn: number, phase: number, owner: string, sequence: number, ): string { return JSON.stringify([ 'nexusEmperorSecretAlly', turn, phase, owner, sequence, ]); } export function emperorNexusSignature(record: object): string { return JSON.stringify({ ...record, signature: undefined }); } export function emperorNexusLegacyRevivalModeSupported(g: Game): boolean { return ( !g.expansions.length && g.players.every((p) => ['atreides', 'harkonnen', 'fremen', 'guild', 'beneGesserit'].includes( p.faction, ), ) && (classicNexusModulesProfile(g) || (!!g.leaderSkills && classicNexusLeaderSkillsProfile(g)) || (!g.homeworlds && (!g.leaderSkills || classicNexusLeaderSkillsProfile(g)) && !g.discoveryEnabled && !g.discoveries && !g.discoveryStash && !g.greatMaker && ((!g.techTokens && !g.strongholdCards) || classicNexusModulesProfile(g) || (!!g.leaderSkills && classicNexusLeaderSkillsProfile(g))))) ); } /** Provisional short-group completion: take three whenever eligible; otherwise * take every eligible physical counter, respecting the existing typed cap. */ export function emperorNexusRevivalAmount( p: Pick, advanced: boolean, ): number { return Math.min(EMPEROR_NEXUS_REVIVALS, p.tanks - (p.elites?.tanks ?? 0) + eliteRevivalRemaining(p, advanced)); } /** Choose only the typed composition of the current maximal return. */ export function emperorNexusRevivalElites( p: Player, advanced: boolean, ): number[] { const amount = emperorNexusRevivalAmount(p, advanced); if (amount <= 0) return []; const choices: number[] = []; for (let elite = 0; elite <= amount; elite++) if ( elite <= eliteRevivalRemaining(p, advanced) && elite <= (p.elites?.tanks ?? 0) && amount - elite <= p.tanks - (p.elites?.tanks ?? 0) ) choices.push(elite); return choices; } const poolKeys = 'eliteReserves,eliteRevived,eliteTanks,freeForcesRevived,reserves,revived,spice,tanks'; const receiptKeys = 'advanced,after,before,elite,event,faction,kind,owner,phase,sequence,signature,turn'; const receiptV2Keys = 'advanced,after,before,elite,event,faction,kind,owner,phase,sequence,signature,stage,turn,version'; const receiptV2ShortKeys = 'advanced,after,amount,before,elite,event,faction,kind,owner,phase,sequence,signature,stage,turn,version'; const whole = (n: unknown): n is number => Number.isSafeInteger(n) && (n as number) >= 0; function shape(value: unknown, keys: string): boolean { if (!value || typeof value !== 'object' || Array.isArray(value)) return false; const prototype: unknown = Object.getPrototypeOf(value); return (prototype === Object.prototype || prototype === null) && Object.keys(value).sort().join(',') === keys; } function validPools(pool: EmperorNexusPools): boolean { return shape(pool, poolKeys) && Object.entries(pool).every(([key, n]) => (key === 'freeForcesRevived' && n === null) || whole(n)) && pool.eliteReserves <= pool.reserves && pool.eliteTanks <= pool.tanks && pool.reserves + pool.tanks <= 20; } function samePools(a: EmperorNexusPools, b: EmperorNexusPools): boolean { return Object.keys(b).every(key => a[key as keyof EmperorNexusPools] === b[key as keyof EmperorNexusPools]); } function returnedPools(r: NexusEmperorRevival): EmperorNexusPools { const amount = emperorNexusRevivalCount(r); return { ...r.before, reserves: r.before.reserves + amount, tanks: r.before.tanks - amount, eliteReserves: r.before.eliteReserves + r.elite, eliteTanks: r.before.eliteTanks - r.elite, eliteRevived: r.before.eliteRevived + r.elite, }; } type ReceiptContext = Pick; /** Validate historical evidence only; later ordinary returns and income are not replayed. */ export function validateEmperorNexusRevival( g: ReceiptContext, r: NexusEmperorRevival, ): void { const p = r && g.players.find(p => p.id === r.owner); const legacy = shape(r, receiptKeys); const short = shape(r, receiptV2ShortKeys); const amount = r ? emperorNexusRevivalCount(r) : EMPEROR_NEXUS_REVIVALS; if (!p || !(legacy || shape(r, receiptV2Keys) || short) || r.kind !== 'revival' || r.phase !== 4 || typeof r.owner !== 'string' || !r.owner.trim() || !whole(r.turn) || r.turn < 1 || r.turn > g.turn || r.faction !== p.faction || r.advanced !== g.advanced || !whole(r.sequence) || !whole(r.elite) || r.elite > amount || r.event !== emperorNexusEvent(r.turn, 4, r.owner, r.sequence) || r.signature !== emperorNexusSignature(r) || !validPools(r.before) || r.before.tanks < amount || r.elite > r.before.eliteTanks || amount - r.elite > r.before.tanks - r.before.eliteTanks) throw new Error('Emperor Nexus revival has lost its exact original physical return or allowance.'); if (legacy) { if (g.expansions.length || g.players.some(seat => !['atreides', 'harkonnen', 'fremen', 'guild', 'beneGesserit'].includes(seat.faction)) || (g.advanced && r.before.eliteRevived + r.elite > 1) || (p.faction !== 'fremen' && (r.elite || r.before.eliteTanks || r.before.eliteReserves)) || !r.after || !validPools(r.after) || JSON.stringify(r.after) !== JSON.stringify(returnedPools(r))) throw new Error('Emperor Nexus revival has lost its exact original physical return or allowance.'); return; } const typed = p.faction === 'ixians' || p.faction === 'fremen'; if (r.version !== 2 || (short && r.amount !== 1 && r.amount !== 2) || amount !== emperorNexusRevivalAmount({ faction: r.faction, tanks: r.before.tanks, elites: { reserves: r.before.eliteReserves, tanks: r.before.eliteTanks, revived: r.before.eliteRevived, forces: {} } }, r.advanced) || g.players.some(seat => seat.faction === 'emperor') || (!typed && (r.elite || r.before.eliteTanks || r.before.eliteReserves || r.before.eliteRevived)) || (typed && r.before.eliteReserves + r.before.eliteTanks > (p.faction === 'ixians' ? 7 : 3)) || (g.advanced && p.faction !== 'ixians' && r.before.eliteRevived + r.elite > 1) || (r.before.freeForcesRevived !== null && r.before.freeForcesRevived > r.before.revived) || (r.stage === 'requested' ? r.after !== null : !['returned', 'denied'].includes(r.stage) || !r.after || !validPools(r.after) || !samePools(r.after, r.stage === 'returned' ? returnedPools(r) : r.before))) throw new Error('Emperor Nexus revival has lost its requested, returned or denied source facts.'); } /** Reuse original fresh roster/module predicates, independently of purchase admission. */ function revivalModeSupported(g: Game): boolean { if (nexusCoreProfile(g)) return true; if (g.semutaPreview || g.advancedPreview || g.kullPreview || g.nexusKullPreview || g.guildBetrayalPreview || g.richeseBetrayalPreview || g.nexusIxianReplacementPreview || g.nexusIxianBetrayalPreview || g.nexusHarkonnenBetrayalPreview || g.ecazTreachery) return false; if (g.leaderSkills) return classicNexusLeaderSkillsProfile(g) || pairedNexusLeaderSkillsProfile(g) || singleE1E2NexusLeaderSkillsProfile(g) || mixedE1E2NexusLeaderSkillsProfile(g) || standaloneE3NexusLeaderSkillsProfile(g) || pairedE3NexusLeaderSkillsProfile(g) || mixedE3NexusLeaderSkillsProfile(g); if (classicNexusModulesProfile(g) || pairedNexusModulesProfile(g) || singleE1E2NexusProfile(g) || mixedE1E2NexusProfile(g) || standaloneE3NexusProfile(g) || pairedE3NexusProfile(g) || mixedE3NexusProfile(g)) return true; // Bare classic and paired E1/E2 Nexus predate the optional-module profiles. if (!g.nexusCards || g.techTokens || g.strongholdCards || g.discoveryEnabled || g.discoveries || g.discoveryStash || g.greatMaker || g.players.length < 2 || g.players.length > 6 || new Set(g.players.map(p => p.faction)).size !== g.players.length) return false; if (!g.expansions.length) return g.players.every(p => FACTIONS.some(f => f.id === p.faction && f.expansion === 'base')); if (g.expansions.length !== 1 || !['ix', 'choam'].includes(g.expansions[0])) return false; const expansion = g.expansions[0]; return g.players.every(p => FACTIONS.some(f => f.id === p.faction && (f.expansion === 'base' || f.expansion === expansion))) && g.players.filter(p => FACTIONS.some(f => f.id === p.faction && f.expansion === expansion)).length === 2; } /** Provisional composition, not a publisher clarification or numerical user ruling. */ const revivalPolicy = 'Provisional composed policy: return exactly three additional free physical forces from Tanks whenever three are eligible; otherwise return all one or two eligible forces. Zero cannot spend the card. This short-group policy is not publisher clarification. The return is beyond ordinary force and free-revival allowances. Native typed caps and Tleilaxu prevention still apply; this is not ordinary free-rate revival, so ordinary free blocking (including LaLaLa) and higher or expanded rates do not reprice it. Recruits does not double the grant. Spend the card on declaration; denial returns no forces and grants no revival income or Tech benefit. No leader, missing-native-Emperor payment, extra Karama response or refund is granted. Actual returns continue through native Tleilaxu, Tech and Homeworld handling.'; export function quoteEmperorNexusRevival(g: Game, owner: string): { event: string; amount: number; blocked: string | null; eliteOptions: number[]; policy: string; } { const p = g.players.find(p => p.id === owner); const event = emperorNexusEvent(g.turn, g.phase, owner, g.nexusEmperorSecretHistory?.length ?? 0); const amount = p ? emperorNexusRevivalAmount(p, g.advanced) : 0; const eliteOptions = p ? emperorNexusRevivalElites(p, g.advanced) : []; let blocked: string | null = null; if (!p || g.nexusCards?.cards?.hands[owner] !== 'emperor') blocked = 'Only the actual holder of the Emperor Nexus card may use this revival.'; else if (g.players.some(p => p.faction === 'emperor')) blocked = 'Emperor Secret Ally requires the native Emperor absent from the roster.'; else if (p.ally) blocked = 'Emperor Nexus Secret Ally requires an unallied holder.'; else if (!revivalModeSupported(g)) blocked = 'Emperor Nexus revival requires an original supported Nexus roster and module profile.'; else if (g.status !== 'playing' || g.phase !== 4) blocked = 'Use the additional Emperor Nexus return during Revival.'; else if (g.response || g.decision || g.truthtrance || g.phaseOpening || g.pendingRevival || g.pendingKarama || g.pendingTreacheryDiscard || g.pendingNullentropy || g.pendingExchange || g.pendingRicheseGift || g.pendingRichesePurchaseIncome || g.pendingAmbassador || g.pendingCapture || g.battle || g.nexusTraitorPending || g.nexusTraitorParent || g.nexusCards?.phase?.stage === 'drawing' || (g.homeworldRevivalReturn && g.homeworldRevivalReturn.stage !== 'complete')) blocked = 'Finish the current interaction before using Emperor Nexus revival.'; else if (revivalPrevented(g, owner)) blocked = 'Native Tleilaxu prevention blocks this revival.'; else if (!eliteOptions.length) blocked = 'Emperor Nexus needs at least one eligible force in Tanks.'; return { event, amount, blocked, eliteOptions, policy: revivalPolicy }; } /** Pure declaration receipt: the engine commits card custody and the original pending revival. */ export function createEmperorNexusRevival( g: Game, owner: string, elite: number, ): NexusEmperorRevivalRequest { const quote = quoteEmperorNexusRevival(g, owner); if (quote.blocked || !quote.eliteOptions.includes(elite)) throw new Error(quote.blocked ?? 'Choose an eligible typed group for the current additional return.'); const p = g.players.find(p => p.id === owner)!; const r: NexusEmperorRevivalRequest = { kind: 'revival', version: 2, event: quote.event, owner, turn: g.turn, phase: 4, sequence: g.nexusEmperorSecretHistory?.length ?? 0, faction: p.faction, advanced: g.advanced, elite, ...(quote.amount === 1 || quote.amount === 2 ? { amount: quote.amount } : {}), before: emperorNexusPools(p), after: null, stage: 'requested', signature: '', }; r.signature = emperorNexusSignature(r); validateEmperorNexusRevival(g, r); return r; } /** Capture the actual physical finish before native income or Homeworld deployment. */ export function settleEmperorNexusRevival( g: Game, receipt: NexusEmperorRevival, outcome: 'returned' | 'denied', ): Extract { validateEmperorNexusRevival(g, receipt); const saved = g.nexusEmperorSecretHistory?.[receipt.sequence]; const p = g.players.find(p => p.id === receipt.owner)!; if (receipt.version !== 2 || receipt.stage !== 'requested' || !['returned', 'denied'].includes(outcome) || receipt.turn !== g.turn || g.phase !== 4 || g.status !== 'playing' || p.ally || !saved || saved.signature !== receipt.signature || emperorNexusSignature(saved) !== receipt.signature || g.nexusEmperorSecretEvents?.[receipt.sequence] !== receipt.event || g.nexusEmperorSecretHistory?.length !== receipt.sequence + 1 || g.nexusCards?.cards?.hands[receipt.owner] !== null || g.nexusCards.cards.discard.filter(card => card === 'emperor').length !== 1) throw new Error('Emperor Nexus settlement needs the original committed requested source.'); const result = { ...receipt, stage: outcome, after: emperorNexusPools(p), signature: '', }; result.signature = emperorNexusSignature(result); validateEmperorNexusRevival(g, result); return result; }