Files
Dune/game/revival-cancellation.ts
T

413 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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' };
}