Files
Dune/game/nexus-emperor-secret-ally.ts
T

351 lines
16 KiB
TypeScript

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<Player, 'tanks' | 'faction' | 'elites'>,
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<Game, 'players' | 'turn' | 'advanced' | 'expansions'>;
/** 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<NexusEmperorRevival, { version: 2; stage: 'returned' | 'denied' }> {
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;
}