344 lines
12 KiB
TypeScript
344 lines
12 KiB
TypeScript
import type { Game, ResponseWindow, Player } from './engine';
|
|
import { isAuditorLeader } from './choam-auditor';
|
|
import { controlsLeader } from './leader-control';
|
|
import {
|
|
eliteRevivalRemaining,
|
|
forceRevivalLimit,
|
|
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'
|
|
| 'players'
|
|
| 'revivalRules'
|
|
| 'freeRevival'
|
|
| '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;
|
|
|
|
export function continuationCustody(
|
|
g: RevivalCancellationContext,
|
|
p: Player,
|
|
pending: Pending,
|
|
) {
|
|
if (pending.kind === 'forces') {
|
|
const n = pending.amount!,
|
|
elite = pending.elite ?? 0;
|
|
requireRevival(
|
|
nonnegative(p.tanks) && nonnegative(p.reserves) && n <= p.tanks,
|
|
'The pending revival forces are no longer in the tanks.',
|
|
);
|
|
requireRevival(
|
|
nonnegative(p.reserves + n) &&
|
|
nonnegative(p.revived) &&
|
|
(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 &&
|
|
n - elite <= p.tanks - eliteTanks &&
|
|
elite <= eliteRevivalRemaining(p),
|
|
'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;
|
|
}
|
|
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) &&
|
|
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.',
|
|
);
|
|
} else {
|
|
requireRevival(
|
|
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 {
|
|
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! > forceRevivalLimit(context, p))
|
|
return { rules, pending: null, outcome: 'abandoned' };
|
|
Object.assign(pending, forceRevivalQuote(context, p, pending.amount!));
|
|
pending.checks = [];
|
|
if (p.revived + pending.amount! > 3) 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' };
|
|
}
|