364 lines
12 KiB
TypeScript
364 lines
12 KiB
TypeScript
import type { Decision, Game, ResponseWindow } from './engine';
|
|
import type { PendingRevival } from './revival';
|
|
import { forceRevivalRemaining } 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';
|
|
|
|
export type RevivalResumeContext = Pick<
|
|
Game,
|
|
| 'status'
|
|
| 'phase'
|
|
| 'turn'
|
|
| 'advanced'
|
|
| 'players'
|
|
| 'pendingRevival'
|
|
| 'revivalRules'
|
|
| 'freeRevival'
|
|
| 'emperorExtra'
|
|
| 'techTokens'
|
|
| 'revivalFreeIncome'
|
|
| 'dukeVidal'
|
|
| 'revivalRequests'
|
|
>;
|
|
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;
|
|
|
|
/** 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.',
|
|
);
|
|
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;
|
|
const elite = original.elite ?? 0;
|
|
const freeCyborgs = paid * 2 + elite - original.normalCost;
|
|
requireResume(
|
|
count(paid * 2) &&
|
|
(p.faction === 'ixians'
|
|
? 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.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.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 &&
|
|
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 {
|
|
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)
|
|
return {
|
|
...quote,
|
|
outcome: 'decision',
|
|
nextDecision: {
|
|
kind: 'revivalStop',
|
|
player: tleilaxu.id,
|
|
recipient: p.id,
|
|
revival: pending.kind === 'forces' ? 'forces' : 'leader',
|
|
},
|
|
};
|
|
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 &&
|
|
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.',
|
|
);
|
|
const free = pending.free > 0;
|
|
const reward = free && lastFree !== g.turn;
|
|
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;
|
|
}
|