413 lines
15 KiB
TypeScript
413 lines
15 KiB
TypeScript
import type { Game, ResponseWindow, Player } from './engine';
|
||
import { DUKE_VIDAL_ID } from './duke-vidal';
|
||
import { resolveEcazDukeRevival } from './ecaz-duke-revival';
|
||
import { isAuditorLeader } from './choam-auditor';
|
||
import { controlsLeader } from './leader-control';
|
||
import { ixHomeworldRevivalEnabled, quoteIxSuboidBonus } from './ix-homeworld-revival';
|
||
import {
|
||
eliteRevivalRemaining,
|
||
normalForceRevivalLimit,
|
||
freeRevivalRate,
|
||
forceRevivalQuote,
|
||
forceRevivalRemaining,
|
||
newRevivalRules,
|
||
type RevivalRules,
|
||
} from './revival';
|
||
|
||
type Pending = NonNullable<Game['pendingRevival']>;
|
||
export type RevivalCancellationKind =
|
||
| 'choamRevival'
|
||
| 'revivalLimit'
|
||
| 'revivalDiscount'
|
||
| 'earlyRevival'
|
||
| 'foreignGhola';
|
||
export type RevivalCancellationContext = Pick<
|
||
Game,
|
||
| 'status'
|
||
| 'phase'
|
||
| 'turn'
|
||
| 'advanced'
|
||
| 'homeworlds'
|
||
| 'homeworldOccupationHistory'
|
||
| 'homeworldOccupationPreview'
|
||
| 'players'
|
||
| 'revivalRules'
|
||
| 'freeRevival'
|
||
| 'recruits'
|
||
| 'dukeVidal'
|
||
| 'pendingRevival'
|
||
| 'emperorExtra'
|
||
>;
|
||
export type RevivalCancellationQuote = {
|
||
rules: RevivalRules;
|
||
pending: Pending | null;
|
||
outcome: 'abandoned' | 'response' | 'unfunded' | 'fremenLimit' | 'revive';
|
||
nextResponse?: {
|
||
kind: RevivalCancellationKind;
|
||
owner: string;
|
||
recipient: string;
|
||
};
|
||
block?: {
|
||
kind: 'earlyRevival' | 'foreignGhola';
|
||
player: string;
|
||
leader: string;
|
||
};
|
||
};
|
||
export class RevivalCancellationError extends Error {
|
||
constructor(message: string) {
|
||
super(message);
|
||
this.name = 'RevivalCancellationError';
|
||
}
|
||
}
|
||
function requireRevival(
|
||
condition: unknown,
|
||
message: string,
|
||
): asserts condition {
|
||
if (!condition) throw new RevivalCancellationError(message);
|
||
}
|
||
const nonnegative = (n: unknown): n is number =>
|
||
Number.isSafeInteger(n) && (n as number) >= 0;
|
||
|
||
/** The Emperor Nexus return is its own fixed source: it moves real Tanks and
|
||
* reserves without incrementing the ordinary revived/free revival ledgers or
|
||
* the Emperor-extra allowance, so those counters are never re-floated for it.
|
||
* Native physical and typed caps below still apply. */
|
||
export function continuationCustody(
|
||
g: RevivalCancellationContext,
|
||
p: Player,
|
||
pending: Pending,
|
||
) {
|
||
if (pending.ixSuboidBonus !== undefined) {
|
||
requireRevival(pending.kind === 'forces' && !pending.emperorExtra &&
|
||
pending.nexusEmperorEvent === undefined && pending.payer === undefined &&
|
||
p.faction === 'ixians' && nonnegative(pending.ixSuboidBonus) &&
|
||
pending.ixSuboidBonus > 0 && pending.ixSuboidBonus <= 13 &&
|
||
nonnegative(pending.amount) && nonnegative(pending.elite) &&
|
||
nonnegative(pending.free) && nonnegative(pending.normalCost) &&
|
||
pending.cost > 0,
|
||
'The Ix bonus needs its original paid native Cyborg request.');
|
||
const freeElite = (pending.amount! - pending.free) * 2 +
|
||
pending.elite! - pending.normalCost;
|
||
let expected = -1;
|
||
try {
|
||
expected = quoteIxSuboidBonus({
|
||
enabled: ixHomeworldRevivalEnabled(g, p.id),
|
||
amount: pending.amount!, elite: pending.elite!, free: pending.free,
|
||
freeElite, normalTanks: p.tanks - (p.elites?.tanks ?? 0),
|
||
});
|
||
} catch { /* Convert malformed physical quotes into the native rule error. */ }
|
||
requireRevival(expected === pending.ixSuboidBonus,
|
||
'The pending Ix bonus no longer matches its paid Cyborgs and physical Suboids.');
|
||
}
|
||
if (pending.kind === 'forces') {
|
||
const n = pending.amount!,
|
||
elite = pending.elite ?? 0,
|
||
physical = n + (pending.ixSuboidBonus ?? 0);
|
||
requireRevival(
|
||
nonnegative(p.tanks) && nonnegative(p.reserves) && physical <= p.tanks,
|
||
'The pending revival forces are no longer in the tanks.',
|
||
);
|
||
requireRevival(
|
||
nonnegative(p.reserves + physical) &&
|
||
nonnegative(p.revived) &&
|
||
(pending.nexusEmperorEvent !== undefined ||
|
||
(pending.emperorExtra
|
||
? nonnegative(g.emperorExtra[p.id] ?? 0) &&
|
||
nonnegative((g.emperorExtra[p.id] ?? 0) + n)
|
||
: nonnegative(p.revived + n) &&
|
||
((p.freeForcesRevived === undefined && p.revived !== 0) ||
|
||
(nonnegative(p.freeForcesRevived ?? 0) &&
|
||
nonnegative((p.freeForcesRevived ?? 0) + pending.free))))),
|
||
'The pending revival would overflow its force or revival counters.',
|
||
);
|
||
if (p.elites)
|
||
requireRevival(
|
||
nonnegative(p.elites.reserves) &&
|
||
p.elites.reserves <= p.reserves &&
|
||
nonnegative(p.elites.revived) &&
|
||
nonnegative(p.elites.reserves + elite) &&
|
||
nonnegative(p.elites.revived + elite),
|
||
'The pending revival would overflow its elite counters.',
|
||
);
|
||
const eliteTanks = p.elites?.tanks ?? 0;
|
||
requireRevival(
|
||
nonnegative(eliteTanks) &&
|
||
eliteTanks <= p.tanks &&
|
||
elite <= eliteTanks &&
|
||
physical - elite <= p.tanks - eliteTanks &&
|
||
elite <= eliteRevivalRemaining(p, g.advanced),
|
||
'The pending ordinary and elite revival groups no longer match their tanks.',
|
||
);
|
||
return;
|
||
}
|
||
if (pending.kind === 'kwisatz') {
|
||
requireRevival(g.advanced, 'Kwisatz revival requires Advanced rules.');
|
||
requireRevival(
|
||
p.faction === 'atreides' &&
|
||
p.kwisatz?.dead &&
|
||
!p.leaderRevived &&
|
||
nonnegative(p.revivalCycle) &&
|
||
nonnegative(p.kwisatz.revivalCycle ?? 1) &&
|
||
nonnegative(Math.max(p.revivalCycle, p.kwisatz.revivalCycle ?? 1) + 1),
|
||
'The pending Kwisatz Haderach is no longer available for revival.',
|
||
);
|
||
return;
|
||
}
|
||
if (pending.leader === DUKE_VIDAL_ID) {
|
||
requireRevival(pending.kind === 'leader' && p.faction === 'ecaz' &&
|
||
pending.normalCost === 5 && (pending.cost === 3 || pending.cost === 5) &&
|
||
!pending.checks.includes('earlyRevival'),
|
||
'The pending shared leader has invalid revival costs or checks.');
|
||
try {
|
||
resolveEcazDukeRevival(g, p.id);
|
||
} catch {
|
||
throw new RevivalCancellationError('The pending shared leader is unavailable for revival.');
|
||
}
|
||
requireRevival(!p.leaderRevived,
|
||
'The pending shared leader has already used this phase’s revival slot.');
|
||
return;
|
||
}
|
||
const matches = g.players
|
||
.flatMap((owner) => owner.leaders.map((leader) => ({ owner, leader })))
|
||
.filter(({ leader }) => leader.id === pending.leader);
|
||
requireRevival(
|
||
matches.length === 1,
|
||
'The pending revival needs one physical leader.',
|
||
);
|
||
const { owner, leader } = matches[0];
|
||
requireRevival(
|
||
leader.dead && !leader.capturedBy,
|
||
'The pending leader is no longer available in the tanks.',
|
||
);
|
||
if (pending.kind === 'foreignGhola') {
|
||
requireRevival(
|
||
p.faction === 'tleilaxu' &&
|
||
leader.faction !== p.faction &&
|
||
!isAuditorLeader(leader),
|
||
'The pending foreign ghola has invalid leader custody.',
|
||
);
|
||
requireRevival(
|
||
g.players
|
||
.flatMap((seat) => seat.leaders)
|
||
.filter((l) => controlsLeader(p, l) && !l.dead).length < 5,
|
||
'Foreign gholas may fill the active leader pool only up to five.',
|
||
);
|
||
} else {
|
||
requireRevival(
|
||
owner.id === p.id &&
|
||
!leader.gholaBy &&
|
||
(!p.leaderRevived || p.faction === 'tleilaxu'),
|
||
'The pending native leader is no longer available to this faction.',
|
||
);
|
||
}
|
||
}
|
||
|
||
/** Pure prediction of the existing canceled-revival branches. No resource,
|
||
* leader, rule, response, or random state is changed. The returned pending
|
||
* keeps its remaining checks unshifted for exactly one real finishRevival. */
|
||
export function quoteRevivalCancellation(
|
||
g: RevivalCancellationContext,
|
||
response: Pick<ResponseWindow, 'kind' | 'owner' | 'recipient'>,
|
||
): RevivalCancellationQuote {
|
||
const original = g.pendingRevival;
|
||
const p = g.players.find((p) => p.id === original?.player);
|
||
const owner = g.players.find((p) => p.id === response.owner);
|
||
requireRevival(
|
||
g.status === 'playing' &&
|
||
g.phase === 4 &&
|
||
Number.isSafeInteger(g.turn) &&
|
||
g.turn >= 1 &&
|
||
original &&
|
||
p &&
|
||
owner &&
|
||
response.recipient === p.id &&
|
||
[
|
||
'choamRevival',
|
||
'revivalLimit',
|
||
'revivalDiscount',
|
||
'earlyRevival',
|
||
'foreignGhola',
|
||
].includes(response.kind),
|
||
'This revival cancellation no longer matches its current owner and recipient.',
|
||
);
|
||
const kind = response.kind as RevivalCancellationKind;
|
||
requireRevival(
|
||
kind === 'choamRevival'
|
||
? owner.id === p.id && p.faction === 'choam' && original.kind === 'forces'
|
||
: owner.faction === 'tleilaxu',
|
||
'This faction does not own the pending revival benefit.',
|
||
);
|
||
requireRevival(
|
||
['forces', 'leader', 'kwisatz', 'foreignGhola'].includes(original.kind) &&
|
||
original.nexusEmperorEvent === undefined &&
|
||
nonnegative(original.cost) &&
|
||
nonnegative(original.normalCost) &&
|
||
original.cost <= original.normalCost &&
|
||
nonnegative(original.free) &&
|
||
Array.isArray(original.checks) &&
|
||
original.checks.length <= 1 &&
|
||
original.checks.every((check) => check === 'revivalDiscount') &&
|
||
(kind !== 'revivalDiscount' || original.checks.length === 0) &&
|
||
(original.payer === undefined ||
|
||
(typeof original.payer === 'string' &&
|
||
original.payer.length > 0 &&
|
||
g.players.some((seat) => seat.id === original.payer))) &&
|
||
(original.emperorExtra === undefined ||
|
||
typeof original.emperorExtra === 'boolean'),
|
||
'The pending revival has invalid costs or remaining benefit checks.',
|
||
);
|
||
requireRevival(
|
||
(kind !== 'revivalLimit' || original.kind === 'forces') &&
|
||
(kind !== 'earlyRevival' ||
|
||
original.kind === 'leader' ||
|
||
original.kind === 'kwisatz') &&
|
||
(kind !== 'foreignGhola' || original.kind === 'foreignGhola') &&
|
||
(original.kind !== 'foreignGhola' || p.faction === 'tleilaxu'),
|
||
'The canceled benefit does not belong to this revival kind.',
|
||
);
|
||
if (original.kind === 'forces') {
|
||
requireRevival(
|
||
nonnegative(original.amount) &&
|
||
original.amount > 0 &&
|
||
nonnegative(original.elite ?? 0) &&
|
||
(original.elite ?? 0) <= original.amount &&
|
||
original.free <= original.amount &&
|
||
!original.leader &&
|
||
nonnegative(p.revived),
|
||
'The pending force revival has invalid physical counts.',
|
||
);
|
||
if (original.salusaFreePolicy !== undefined)
|
||
requireRevival(original.salusaFreePolicy === 1 && g.advanced &&
|
||
p.faction === 'emperor' && !!g.homeworlds?.custody && !original.emperorExtra &&
|
||
original.payer === undefined &&
|
||
original.free === forceRevivalQuote(g, p, original.amount!, original.elite ?? 0).free,
|
||
'The pending Salusa revival no longer matches its original free allocation.');
|
||
} else {
|
||
requireRevival(
|
||
original.salusaFreePolicy === undefined && original.free === 0 &&
|
||
original.amount === undefined &&
|
||
original.elite === undefined &&
|
||
(original.kind === 'kwisatz'
|
||
? original.leader === undefined || original.leader === 'kwisatz'
|
||
: typeof original.leader === 'string' && !!original.leader),
|
||
'The pending leader revival has invalid identity or force fields.',
|
||
);
|
||
if (original.kind === 'kwisatz') {
|
||
requireRevival(
|
||
g.advanced && p.faction === 'atreides' && !!p.kwisatz,
|
||
'The pending Kwisatz Haderach belongs to no valid revival pool.',
|
||
);
|
||
} else if (original.leader === DUKE_VIDAL_ID) {
|
||
requireRevival(original.kind === 'leader' && p.faction === 'ecaz' &&
|
||
original.normalCost === 5 && (original.cost === 3 || original.cost === 5) &&
|
||
kind !== 'earlyRevival',
|
||
'The pending shared leader has invalid revival costs or checks.');
|
||
try {
|
||
resolveEcazDukeRevival(g, p.id);
|
||
} catch {
|
||
throw new RevivalCancellationError('The pending shared leader is unavailable for revival.');
|
||
}
|
||
} else {
|
||
const matches = g.players
|
||
.flatMap((seat) => seat.leaders.map((leader) => ({ seat, leader })))
|
||
.filter(({ leader }) => leader.id === original.leader);
|
||
requireRevival(
|
||
matches.length === 1 &&
|
||
(original.kind === 'leader'
|
||
? matches[0].seat.id === p.id
|
||
: g.advanced &&
|
||
matches[0].leader.faction !== p.faction &&
|
||
!isAuditorLeader(matches[0].leader)),
|
||
'The pending revival needs its original physical leader pool.',
|
||
);
|
||
}
|
||
}
|
||
const rules = { ...newRevivalRules(), ...g.revivalRules };
|
||
requireRevival(
|
||
Array.isArray(rules.expanded) &&
|
||
Array.isArray(rules.earlyBlocked) &&
|
||
(rules.freeBlocked === undefined || Array.isArray(rules.freeBlocked)),
|
||
'The pending revival rule context is invalid.',
|
||
);
|
||
rules.expanded = [...rules.expanded];
|
||
rules.earlyBlocked = [...rules.earlyBlocked];
|
||
if (rules.freeBlocked) rules.freeBlocked = [...rules.freeBlocked];
|
||
const pending: Pending = { ...original, checks: [...original.checks] };
|
||
const context = { ...g, revivalRules: rules };
|
||
if (kind === 'revivalLimit') {
|
||
rules.limitBlocked = true;
|
||
rules.expanded = [];
|
||
return { rules, pending: null, outcome: 'abandoned' };
|
||
}
|
||
if (kind === 'earlyRevival' || kind === 'foreignGhola') {
|
||
const leader = pending.leader ?? 'kwisatz';
|
||
if (kind === 'earlyRevival') rules.earlyBlocked.push(`${p.id}:${leader}`);
|
||
return {
|
||
rules,
|
||
pending: null,
|
||
outcome: 'abandoned',
|
||
block: { kind, player: p.id, leader },
|
||
};
|
||
}
|
||
if (kind === 'choamRevival') {
|
||
rules.choamBlocked = true;
|
||
if (p.revived + pending.amount! > normalForceRevivalLimit(context, p))
|
||
return { rules, pending: null, outcome: 'abandoned' };
|
||
Object.assign(pending, forceRevivalQuote(context, p, pending.amount!));
|
||
pending.checks = [];
|
||
if (p.revived + pending.amount! > Math.max(3, freeRevivalRate(context, p)))
|
||
pending.checks.push('revivalLimit');
|
||
if (pending.cost < pending.normalCost)
|
||
pending.checks.push('revivalDiscount');
|
||
} else {
|
||
if (p.faction === 'tleilaxu') rules.fullPrice = true;
|
||
else {
|
||
rules.allyDiscount = null;
|
||
rules.discountBlocked = true;
|
||
}
|
||
pending.cost =
|
||
pending.kind === 'forces' &&
|
||
p.faction === 'choam' &&
|
||
!pending.emperorExtra
|
||
? forceRevivalQuote(context, p, pending.amount!, pending.elite).cost
|
||
: pending.normalCost;
|
||
}
|
||
const check = pending.checks[0];
|
||
if (!check) {
|
||
if (
|
||
pending.kind === 'forces' &&
|
||
!pending.emperorExtra &&
|
||
p.faction === 'fremen' &&
|
||
pending.amount! > forceRevivalRemaining(context, p)
|
||
)
|
||
return { rules, pending, outcome: 'fremenLimit' };
|
||
const payer = g.players.find(
|
||
(seat) => seat.id === (pending.payer ?? p.id),
|
||
)!;
|
||
requireRevival(
|
||
nonnegative(payer.spice),
|
||
'The revival payer has an invalid spice balance.',
|
||
);
|
||
if (payer.spice < pending.cost)
|
||
return { rules, pending, outcome: 'unfunded' };
|
||
}
|
||
continuationCustody(g, p, pending);
|
||
if (check) {
|
||
const nextOwner =
|
||
check === 'choamRevival'
|
||
? p
|
||
: g.players.find((seat) => seat.faction === 'tleilaxu');
|
||
requireRevival(
|
||
nextOwner,
|
||
'The remaining revival benefit has no faction owner.',
|
||
);
|
||
return {
|
||
rules,
|
||
pending,
|
||
outcome: 'response',
|
||
nextResponse: { kind: check, owner: nextOwner.id, recipient: p.id },
|
||
};
|
||
}
|
||
return { rules, pending, outcome: 'revive' };
|
||
}
|