504 lines
19 KiB
TypeScript
504 lines
19 KiB
TypeScript
import type { Decision, Game, ResponseWindow } from './engine';
|
|
import type { PendingRevival } from './revival';
|
|
import { forceRevivalQuote, forceRevivalRemaining, legacyFreeRevivalRemaining } from './revival';
|
|
import {
|
|
continuationCustody,
|
|
RevivalCancellationError,
|
|
} from './revival-cancellation';
|
|
import { isAuditorLeader } from './choam-auditor';
|
|
import { controlsLeader } from './leader-control';
|
|
import { TECH_TOKENS, ownedTech } from './tech-tokens';
|
|
import { tleilaxuHomeworldFreeIncomeBlocked } from './homeworld-benefits';
|
|
import { DUKE_VIDAL_ID } from './duke-vidal';
|
|
import { resolveEcazDukeRevival } from './ecaz-duke-revival';
|
|
import {
|
|
emperorNexusPools,
|
|
emperorNexusRevivalCount,
|
|
validateEmperorNexusRevival,
|
|
} from './nexus-emperor-secret-ally';
|
|
import { validateNexusCards } from './nexus-cards';
|
|
|
|
export type RevivalResumeContext = Pick<
|
|
Game,
|
|
| 'status'
|
|
| 'phase'
|
|
| 'turn'
|
|
| 'advanced'
|
|
| 'expansions'
|
|
| 'homeworlds'
|
|
| 'homeworldOccupationHistory'
|
|
| 'homeworldOccupationPreview'
|
|
| 'homeworldRevival'
|
|
| 'players'
|
|
| 'pendingRevival'
|
|
| 'revivalRules'
|
|
| 'freeRevival'
|
|
| 'recruits'
|
|
| 'emperorExtra'
|
|
| 'techTokens'
|
|
| 'revivalFreeIncome'
|
|
| 'dukeVidal'
|
|
| 'revivalRequests'
|
|
| 'nexusCards'
|
|
| 'nexusEmperorSecretHistory'
|
|
| 'nexusEmperorSecretEvents'
|
|
>;
|
|
export type RevivalResumeQuote = {
|
|
/** The frozen request, including the unshifted remaining check queue. */
|
|
pending: PendingRevival;
|
|
outcome: 'decision' | 'response' | 'unfunded' | 'fremenLimit' | 'revive';
|
|
nextDecision?: Extract<Decision, { kind: 'revivalStop' }>;
|
|
nextResponse?: ResponseWindow;
|
|
techIncome?: {
|
|
id: 'axlotl';
|
|
owner: string;
|
|
spice: number;
|
|
triggeredTurn: number;
|
|
};
|
|
freeIncome?: { player: string; turn: number };
|
|
};
|
|
export class RevivalResumeError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'RevivalResumeError';
|
|
}
|
|
}
|
|
function requireResume(condition: unknown, message: string): asserts condition {
|
|
if (!condition) throw new RevivalResumeError(message);
|
|
}
|
|
const count = (value: unknown): value is number =>
|
|
Number.isSafeInteger(value) && (value as number) >= 0;
|
|
/** The independent source carries its own event marker and nothing else. */
|
|
const nexusEmperorPendingKeys: Record<string, true> = {
|
|
player: true,
|
|
kind: true,
|
|
amount: true,
|
|
elite: true,
|
|
free: true,
|
|
cost: true,
|
|
normalCost: true,
|
|
checks: true,
|
|
nexusEmperorEvent: true,
|
|
};
|
|
|
|
/** The consumed card authorizes only its one still-requested frozen source.
|
|
* Historical returned/denied receipts never authorize another pending revival. */
|
|
function validateNexusEmperorSource(
|
|
g: RevivalResumeContext,
|
|
pending: PendingRevival,
|
|
): void {
|
|
const event = pending.nexusEmperorEvent;
|
|
requireResume(
|
|
typeof event === 'string' && event.length > 0 &&
|
|
Object.keys(pending).every((key) => nexusEmperorPendingKeys[key]) &&
|
|
pending.kind === 'forces' && Number.isSafeInteger(pending.amount) &&
|
|
pending.amount! >= 1 && pending.amount! <= 3 &&
|
|
Number.isSafeInteger(pending.elite) && pending.elite! >= 0 &&
|
|
pending.elite! <= pending.amount! && pending.free === pending.amount &&
|
|
pending.cost === 0 && pending.normalCost === 0 &&
|
|
pending.payer === undefined && pending.emperorExtra === undefined &&
|
|
pending.leader === undefined && Array.isArray(pending.checks) &&
|
|
pending.checks.length === 0,
|
|
'The Emperor Nexus source needs its original physical free-force request.',
|
|
);
|
|
const owner = g.players.find((p) => p.id === pending.player);
|
|
const history = g.nexusEmperorSecretHistory;
|
|
const events = g.nexusEmperorSecretEvents;
|
|
requireResume(
|
|
owner && !owner.ally &&
|
|
!g.players.some((p) => p.faction === 'emperor') &&
|
|
Array.isArray(history) && Array.isArray(events) &&
|
|
history.length === events.length &&
|
|
new Set(events).size === events.length &&
|
|
history.every((receipt, index) =>
|
|
receipt && typeof receipt === 'object' &&
|
|
receipt.event === events[index] && receipt.sequence === index) &&
|
|
history.filter((receipt) => receipt.event === event).length === 1,
|
|
'The Emperor Nexus request has lost its original owner or source history.',
|
|
);
|
|
const receipt = history.find((entry) => entry.event === event)!;
|
|
const currentPools = emperorNexusPools(owner);
|
|
requireResume(
|
|
receipt.version === 2 && receipt.stage === 'requested' &&
|
|
receipt.owner === owner.id && receipt.turn === g.turn &&
|
|
receipt.phase === g.phase && receipt.elite === pending.elite &&
|
|
emperorNexusRevivalCount(receipt) === pending.amount &&
|
|
receipt.after === null &&
|
|
receipt.before && Object.entries(currentPools).every(([key, value]) =>
|
|
receipt.before[key as keyof typeof currentPools] === value) &&
|
|
history.at(-1) === receipt &&
|
|
history.filter((entry) =>
|
|
entry.version === 2 && entry.stage === 'requested').length === 1,
|
|
'The Emperor Nexus request does not match its still-requested physical source.',
|
|
);
|
|
try {
|
|
validateEmperorNexusRevival(g, receipt);
|
|
const cards = g.nexusCards?.cards;
|
|
requireResume(cards,
|
|
'The Emperor Nexus request has lost its consumed physical card.');
|
|
validateNexusCards(cards, g.players);
|
|
requireResume(
|
|
cards.hands[owner.id] === null && cards.discard.includes('emperor'),
|
|
'The Emperor Nexus request has lost its consumed physical card.',
|
|
);
|
|
} catch (error) {
|
|
if (error instanceof RevivalResumeError) throw error;
|
|
throw new RevivalResumeError(
|
|
error instanceof Error ? error.message : 'The Emperor Nexus source is invalid.',
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Only the next deterministic continuation is quoted. A genuine stop decision
|
|
* or benefit response is a boundary, even when the outer automatic-response
|
|
* loop could later allow it. No benefit is canceled, repriced, or reoffered. */
|
|
export function quoteRevivalResume(
|
|
g: RevivalResumeContext,
|
|
stage: 'stop' | 'finish',
|
|
): RevivalResumeQuote {
|
|
const original = g.pendingRevival;
|
|
requireResume(
|
|
(stage === 'stop' || stage === 'finish') &&
|
|
g.status === 'playing' &&
|
|
g.phase === 4 &&
|
|
count(g.turn) &&
|
|
g.turn > 0 &&
|
|
original &&
|
|
typeof original === 'object' &&
|
|
!Array.isArray(original) &&
|
|
Array.isArray(g.players) &&
|
|
new Set(g.players.map((p) => p.id)).size === g.players.length,
|
|
'The pending revival has no current Revival-phase context.',
|
|
);
|
|
const p = g.players.find((p) => p.id === original.player);
|
|
const tleilaxu = g.players.find((p) => p.faction === 'tleilaxu');
|
|
const payer = g.players.find(
|
|
(p) => p.id === (original.payer ?? original.player),
|
|
);
|
|
requireResume(
|
|
p &&
|
|
payer &&
|
|
['forces', 'leader', 'kwisatz', 'foreignGhola'].includes(original.kind) &&
|
|
count(original.cost) &&
|
|
count(original.normalCost) &&
|
|
original.cost <= original.normalCost &&
|
|
count(original.free) &&
|
|
(original.emperorExtra === undefined ||
|
|
typeof original.emperorExtra === 'boolean') &&
|
|
(original.payer === undefined || typeof original.payer === 'string'),
|
|
'The pending revival has invalid ownership, kind, or frozen costs.',
|
|
);
|
|
requireResume(
|
|
original.emperorExtra
|
|
? original.kind === 'forces' &&
|
|
payer.faction === 'emperor' &&
|
|
payer.id !== p.id &&
|
|
original.free === 0 &&
|
|
original.cost === original.normalCost
|
|
: original.payer === undefined,
|
|
'The pending revival has an invalid Emperor funding source.',
|
|
);
|
|
requireResume(
|
|
!g.nexusEmperorSecretHistory?.some((receipt) =>
|
|
receipt.version === 2 && receipt.stage === 'requested') ||
|
|
original.nexusEmperorEvent !== undefined,
|
|
'The requested Emperor Nexus source is missing from its pending revival.',
|
|
);
|
|
if (original.nexusEmperorEvent !== undefined)
|
|
validateNexusEmperorSource(g, original);
|
|
if (original.salusaFreePolicy !== undefined)
|
|
requireResume(original.salusaFreePolicy === 1 && original.kind === 'forces' &&
|
|
p.faction === 'emperor' && g.advanced && !!g.homeworlds?.custody &&
|
|
!original.emperorExtra && original.nexusEmperorEvent === undefined,
|
|
'The saved Salusa revival policy does not match its ordinary Emperor source.');
|
|
if (original.kind === 'forces') {
|
|
requireResume(
|
|
count(original.amount) &&
|
|
original.amount > 0 &&
|
|
count(original.elite ?? 0) &&
|
|
(original.elite ?? 0) <= original.amount &&
|
|
original.free <= original.amount &&
|
|
original.leader === undefined &&
|
|
count(p.revived),
|
|
'The pending force revival has invalid physical counts.',
|
|
);
|
|
const paid = original.amount - original.free;
|
|
// Supported pending-revival overlays cannot move native forces or consume
|
|
// ordinary revival usage. Validate the accepted group's free allocation
|
|
// before settlement crosses a population threshold. A later independent
|
|
// request gets the rate at its own declaration.
|
|
if (g.homeworlds && !original.emperorExtra &&
|
|
original.nexusEmperorEvent === undefined)
|
|
requireResume(
|
|
original.free === (original.salusaFreePolicy === 1
|
|
? forceRevivalQuote(g, p, original.amount, original.elite ?? 0).free
|
|
: Math.min(original.amount, legacyFreeRevivalRemaining(g, p))),
|
|
'The saved Homeworld revival free allocation does not match its current eligible group.',
|
|
);
|
|
const elite = original.elite ?? 0;
|
|
const freeCyborgs = paid * 2 + elite - original.normalCost;
|
|
requireResume(
|
|
count(paid * 2) &&
|
|
(p.faction === 'ixians' && original.nexusEmperorEvent === undefined
|
|
? count(freeCyborgs) &&
|
|
freeCyborgs >=
|
|
Math.max(0, original.free - (original.amount - elite)) &&
|
|
freeCyborgs <= Math.min(original.free, elite)
|
|
: original.normalCost === paid * 2),
|
|
'The frozen normal revival price does not match its paid physical forces.',
|
|
);
|
|
const prices = [original.normalCost, Math.ceil(original.normalCost / 2)];
|
|
if (p.faction === 'choam' && !original.emperorExtra)
|
|
prices.push(paid, Math.ceil(paid / 2));
|
|
requireResume(
|
|
prices.includes(original.cost),
|
|
'The frozen revival charge does not match a supported force-revival rate.',
|
|
);
|
|
} else {
|
|
requireResume(
|
|
original.free === 0 &&
|
|
original.amount === undefined &&
|
|
original.elite === undefined &&
|
|
!original.emperorExtra &&
|
|
original.nexusEmperorEvent === undefined &&
|
|
(original.kind === 'kwisatz'
|
|
? g.advanced &&
|
|
p.faction === 'atreides' &&
|
|
!!p.kwisatz &&
|
|
(original.leader === undefined || original.leader === 'kwisatz')
|
|
: typeof original.leader === 'string' && original.leader.length > 0),
|
|
'The pending leader revival has invalid identity or force fields.',
|
|
);
|
|
}
|
|
const allowed: PendingRevival['checks'] =
|
|
original.kind === 'forces'
|
|
? [
|
|
p.faction === 'choam' ? 'choamRevival' : 'revivalLimit',
|
|
'revivalDiscount',
|
|
]
|
|
: [
|
|
original.kind === 'foreignGhola' ? 'foreignGhola' : 'earlyRevival',
|
|
'revivalDiscount',
|
|
];
|
|
// CHOAM can also use the ordinary expanded limit after its own benefit was
|
|
// canceled. This is a real finishRevival producer, not a new price calculation.
|
|
if (
|
|
original.kind === 'forces' &&
|
|
p.faction === 'choam' &&
|
|
original.checks?.[0] === 'revivalLimit'
|
|
)
|
|
allowed[0] = 'revivalLimit';
|
|
requireResume(
|
|
Array.isArray(original.checks) &&
|
|
original.checks.length <= 2 &&
|
|
original.checks.every(
|
|
(check, index) =>
|
|
allowed.includes(check) &&
|
|
(index === 0 ||
|
|
allowed.indexOf(check) >
|
|
allowed.indexOf(original.checks[index - 1])),
|
|
) &&
|
|
(!(original.emperorExtra || original.nexusEmperorEvent !== undefined) ||
|
|
original.checks.length === 0) &&
|
|
original.checks.every((check) => check === 'choamRevival' || !!tleilaxu),
|
|
'The pending revival has invalid ordered benefit checks or no benefit owner.',
|
|
);
|
|
if (original.kind === 'foreignGhola')
|
|
requireResume(
|
|
g.advanced && p.faction === 'tleilaxu',
|
|
'Foreign ghola revival requires Advanced Tleilaxu.',
|
|
);
|
|
const pending: PendingRevival = { ...original, checks: [...original.checks] };
|
|
const quote: RevivalResumeQuote = { pending, outcome: 'revive' };
|
|
const stop =
|
|
stage === 'stop' &&
|
|
g.advanced &&
|
|
tleilaxu &&
|
|
!tleilaxu.specialKaramaUsed &&
|
|
tleilaxu.id !== p.id;
|
|
const check = pending.checks[0];
|
|
|
|
// These are existing noncommitting fizzle branches. Do not require the
|
|
// denied forces/leader or destination capacity to complete an unfunded request.
|
|
if (!stop && !check) {
|
|
if (
|
|
pending.kind === 'forces' &&
|
|
!pending.emperorExtra &&
|
|
pending.nexusEmperorEvent === undefined &&
|
|
p.faction === 'fremen'
|
|
) {
|
|
requireResume(
|
|
Array.isArray(g.freeRevival) &&
|
|
count(p.tanks) &&
|
|
(!g.revivalRules ||
|
|
(Array.isArray(g.revivalRules.expanded) &&
|
|
(g.revivalRules.freeBlocked === undefined ||
|
|
Array.isArray(g.revivalRules.freeBlocked)))),
|
|
'The pending Fremen revival has invalid current allowance context.',
|
|
);
|
|
if (pending.amount! > forceRevivalRemaining(g, p))
|
|
return { ...quote, outcome: 'fremenLimit' };
|
|
}
|
|
requireResume(
|
|
count(payer.spice),
|
|
'The revival payer has an invalid spice balance.',
|
|
);
|
|
if (payer.spice < pending.cost) return { ...quote, outcome: 'unfunded' };
|
|
}
|
|
|
|
if (pending.kind === 'forces' || pending.kind === 'kwisatz') {
|
|
requireResume(
|
|
!pending.emperorExtra ||
|
|
(g.emperorExtra && typeof g.emperorExtra === 'object'),
|
|
'The Emperor extra-revival counters are unavailable.',
|
|
);
|
|
try {
|
|
continuationCustody(g, p, pending);
|
|
} catch (error) {
|
|
if (error instanceof RevivalCancellationError)
|
|
throw new RevivalResumeError(error.message);
|
|
throw error;
|
|
}
|
|
} else {
|
|
if (pending.leader === DUKE_VIDAL_ID) {
|
|
requireResume(pending.kind === 'leader' && p.faction === 'ecaz' &&
|
|
pending.normalCost === 5 && (pending.cost === 3 || pending.cost === 5) &&
|
|
!pending.checks.includes('earlyRevival') && !p.leaderRevived,
|
|
'The pending shared leader has invalid ownership, cost, or revival slot.');
|
|
try {
|
|
resolveEcazDukeRevival(g, p.id);
|
|
} catch {
|
|
throw new RevivalResumeError('The pending shared leader is unavailable for revival.');
|
|
}
|
|
} else {
|
|
const matches = g.players
|
|
.flatMap((owner) => owner.leaders.map((leader) => ({ owner, leader })))
|
|
.filter(({ leader }) => leader.id === pending.leader);
|
|
requireResume(
|
|
matches.length === 1 &&
|
|
matches[0].leader.dead &&
|
|
!matches[0].leader.capturedBy,
|
|
'The pending revival needs one dead, uncaptured physical leader.',
|
|
);
|
|
const { owner, leader } = matches[0];
|
|
if (pending.kind === 'foreignGhola') {
|
|
const living =
|
|
g.players
|
|
.flatMap((seat) => seat.leaders)
|
|
.filter((leader) => controlsLeader(p, leader) && !leader.dead)
|
|
.length +
|
|
(g.dukeVidal?.controller === p.id &&
|
|
!g.dukeVidal.leader.dead &&
|
|
!g.dukeVidal.leader.capturedBy &&
|
|
!g.dukeVidal.leader.gholaBy &&
|
|
!(g.advanced && g.players.some((seat) => seat.faction === 'harkonnen'))
|
|
? 1
|
|
: 0);
|
|
requireResume(
|
|
leader.faction !== p.faction && !isAuditorLeader(leader) && living < 5,
|
|
'The pending foreign ghola has invalid leader identity or active pool capacity.',
|
|
);
|
|
} else {
|
|
// A negotiated native buyback may still carry the Tleilaxu ghola marker.
|
|
// The original approved request survives while benefit checks are answered.
|
|
const request = g.revivalRequests?.[p.id];
|
|
const buyback =
|
|
!!tleilaxu &&
|
|
leader.gholaBy === tleilaxu.id &&
|
|
request?.leader === leader.id &&
|
|
request.price === pending.cost &&
|
|
!request.declined;
|
|
requireResume(
|
|
owner.id === p.id &&
|
|
(!leader.gholaBy || buyback) &&
|
|
(!p.leaderRevived || p.faction === 'tleilaxu'),
|
|
'The pending native leader no longer belongs to this revival request.',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
if (stop) {
|
|
const nextDecision: NonNullable<RevivalResumeQuote['nextDecision']> = {
|
|
kind: 'revivalStop', player: tleilaxu.id, recipient: p.id,
|
|
revival: pending.kind === 'forces' ? 'forces' : 'leader',
|
|
};
|
|
if (pending.nexusEmperorEvent) nextDecision.source = 'nexusEmperor';
|
|
return { ...quote, outcome: 'decision', nextDecision };
|
|
}
|
|
if (check)
|
|
return {
|
|
...quote,
|
|
outcome: 'response',
|
|
nextResponse: {
|
|
kind: check,
|
|
owner: check === 'choamRevival' ? p.id : tleilaxu!.id,
|
|
recipient: p.id,
|
|
passed: [],
|
|
},
|
|
};
|
|
if (
|
|
pending.kind === 'forces' &&
|
|
(pending.free > 0 || (pending.ixSuboidBonus ?? 0) > 0) &&
|
|
g.techTokens &&
|
|
p.faction !== 'tleilaxu'
|
|
) {
|
|
const token = g.techTokens.axlotl;
|
|
requireResume(
|
|
token && typeof token === 'object',
|
|
'Axlotl Tanks technology state is unavailable.',
|
|
);
|
|
if (token.owner && token.triggeredTurn !== g.turn) {
|
|
requireResume(
|
|
g.players.some((p) => p.id === token.owner) &&
|
|
count(token.spice) &&
|
|
(token.triggeredTurn === undefined || count(token.triggeredTurn)) &&
|
|
TECH_TOKENS.every((definition) => {
|
|
const entry = g.techTokens![definition.id];
|
|
return (
|
|
!!entry &&
|
|
(entry.owner === null ||
|
|
g.players.some((p) => p.id === entry.owner))
|
|
);
|
|
}),
|
|
'Axlotl Tanks technology income has invalid token custody.',
|
|
);
|
|
quote.techIncome = {
|
|
id: 'axlotl',
|
|
owner: token.owner,
|
|
spice: ownedTech(g.techTokens, token.owner).length,
|
|
triggeredTurn: g.turn,
|
|
};
|
|
}
|
|
}
|
|
if (tleilaxu) {
|
|
const lastFree = g.revivalFreeIncome?.[payer.id];
|
|
requireResume(
|
|
lastFree === undefined || count(lastFree),
|
|
'The revival free-income receipt is invalid.',
|
|
);
|
|
// The physical Homeworld grant is one free-return event within the same
|
|
// invoice, without spending the ordinary free-rate ledger.
|
|
const free = pending.free > 0 || (pending.ixSuboidBonus ?? 0) > 0;
|
|
const reward =
|
|
free &&
|
|
lastFree !== g.turn &&
|
|
!(payer.id !== tleilaxu.id && tleilaxuHomeworldFreeIncomeBlocked(g));
|
|
if (free) quote.freeIncome = { player: payer.id, turn: g.turn };
|
|
const amount =
|
|
(payer.id === tleilaxu.id ? 0 : pending.cost) + (reward ? 1 : 0);
|
|
requireResume(
|
|
count(amount),
|
|
'The resulting revival income exceeds a safe spice amount.',
|
|
);
|
|
if (amount)
|
|
quote.nextResponse = {
|
|
kind: 'revivalIncome',
|
|
owner: tleilaxu.id,
|
|
recipient: payer.id,
|
|
amount,
|
|
passed: [],
|
|
};
|
|
}
|
|
return quote;
|
|
}
|