Files
Dune/game/terminal-cancellation.ts
Quintine 9081249574
Verify and publish checkpoint container / container (push) Successful in 10m13s
Support staged group Emperor aid
2026-10-28 23:33:37 +11:00

348 lines
12 KiB
TypeScript

import {
validateGuildAmbassadorArrivalContext,
GuildAmbassadorContinuationError,
} from './guild-ambassador-continuation';
import type { Game, ResponseWindow } from './engine';
import { coalitionOf, CoalitionAllianceError } from './coalition-alliance';
import {
homeworldMovementForesightBlock,
homeworldSpiritualAdvisorAllowance,
} from './homeworld-mobility';
import {
gameTerritories,
location,
splitLocation,
validLocation,
} from './board';
export class TerminalCancellationError extends Error {
constructor(message: string) {
super(message);
this.name = 'TerminalCancellationError';
}
}
export type TerminalCancellationKind =
| 'advisor'
| 'emperorGift'
| 'emperorRevival'
| 'guildIncome'
| 'revivalIncome'
| 'bgCharity'
| 'choamCharity'
| 'choamInflation'
| 'stormPeek'
| 'atreidesSpice'
| 'atreidesAuction'
| 'faceDancerReplacement';
type NoEffectKind = Exclude<
TerminalCancellationKind,
'stormPeek' | 'atreidesSpice' | 'atreidesAuction' | 'choamCharity' | 'advisor'
>;
export type TerminalCancellationQuote =
| { kind: NoEffectKind; owner: string }
| { kind: 'advisor'; owner: string; successor?: 'ambassador' }
| {
kind: 'stormPeek' | 'atreidesSpice' | 'atreidesAuction';
owner: string;
known: false;
}
| {
kind: 'choamCharity';
owner: string;
receipt: { turn: number; canceled: true };
};
export type TerminalCancellationContext = Pick<
Game,
| 'status'
| 'phase'
| 'turn'
| 'advanced'
| 'players'
| 'coalitionState'
| 'auction'
| 'richeseAuction'
| 'stormCard'
| 'spiceDeck'
| 'mobileStronghold'
| 'inflationAttemptTurn'
| 'inflation'
| 'inflationUsed'
| 'choamCharity'
| 'pendingAmbassador'
| 'ecazAmbassadors'
| 'homeworlds'
| 'homeworldRevivalReturn'
| 'homeworldRevivalProgress'
| 'homeworldVictoryReinforcement'
| 'lastBattleContext'
>;
const positive = (n: unknown): n is number =>
Number.isSafeInteger(n) && (n as number) > 0;
const nonnegative = (n: unknown): n is number =>
Number.isSafeInteger(n) && (n as number) >= 0;
const object = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
function requireTerminal(
condition: unknown,
message: string,
): asserts condition {
if (!condition) throw new TerminalCancellationError(message);
}
/** Only the immediate canceled branch is proved. No denied transfer, revival,
* placement, replacement, future choice, automatic continuation or random draw
* is executed or required. Results contain no private card or leader identity. */
export function validateTerminalCancellation(
g: TerminalCancellationContext,
response: ResponseWindow,
): TerminalCancellationQuote | null {
if (
![
'advisor',
'emperorGift',
'emperorRevival',
'guildIncome',
'revivalIncome',
'bgCharity',
'choamCharity',
'choamInflation',
'stormPeek',
'atreidesSpice',
'atreidesAuction',
'faceDancerReplacement',
].includes(response.kind)
)
return null;
// This response resumes settlement in the separate Richese auction contract.
if (response.kind === 'atreidesAuction' && g.richeseAuction) return null;
requireTerminal(
g.status === 'playing' &&
positive(g.turn) &&
nonnegative(g.phase) &&
g.phase <= 8 &&
Array.isArray(g.players),
'This canceled power needs its current active game.',
);
const owner = g.players.find((p) => p.id === response.owner);
requireTerminal(owner, 'This canceled power has no seated owner.');
const recipient = () => g.players.find((p) => p.id === response.recipient);
const faction = (expected: string) =>
requireTerminal(
owner.faction === expected,
'This faction does not own the canceled power.',
);
const amount = () =>
requireTerminal(
positive(response.amount),
'The canceled power needs its declared positive whole amount.',
);
switch (response.kind) {
case 'emperorGift':
case 'emperorRevival': {
faction('emperor');
amount();
let allied = owner.ally === response.recipient;
if (!allied && g.coalitionState) {
try {
const group = coalitionOf(g.players, g.coalitionState, owner.id);
allied = !!group && group.members.length === 3 && !!response.recipient &&
group.members.includes(response.recipient);
} catch (error) {
if (error instanceof CoalitionAllianceError) throw new TerminalCancellationError(error.message);
throw error;
}
}
requireTerminal(
recipient() &&
response.recipient !== owner.id &&
allied,
'The canceled Emperor power needs its originally declared ally.',
);
const elite = response.elite ?? 0;
requireTerminal(
nonnegative(elite) && elite <= response.amount!,
'The canceled Emperor power has invalid declared force types.',
);
if (response.kind === 'emperorRevival')
requireTerminal(
g.phase === 4 && response.amount! <= 3,
'Canceled extra revival requires its Revival-phase declaration.',
);
else
requireTerminal(
elite === 0,
'A spice gift has no elite force allocation.',
);
return { kind: response.kind, owner: owner.id };
}
case 'guildIncome':
faction('guild');
amount();
requireTerminal(
g.phase === 5,
'Canceled Guild shipment income requires Shipment and Movement.',
);
return { kind: response.kind, owner: owner.id };
case 'revivalIncome':
faction('tleilaxu');
amount();
requireTerminal(
recipient(),
'Canceled revival income needs its original payer.',
);
// Ghola produces the same receipt outside Revival, including Tleilaxu's
// own free/card-return reward: owner and recipient may be the same seat.
return { kind: response.kind, owner: owner.id };
case 'bgCharity':
faction('beneGesserit');
amount();
requireTerminal(
g.advanced && g.phase === 2 && owner.charityTurn === g.turn,
'Canceled Bene Gesserit charity needs its current Advanced claim.',
);
return { kind: response.kind, owner: owner.id };
case 'choamCharity':
faction('choam');
requireTerminal(
g.phase === 2 && g.choamCharity?.turn !== g.turn,
'Canceled CHOAM charity income needs its unresolved phase opening.',
);
return {
kind: response.kind,
owner: owner.id,
receipt: { turn: g.turn, canceled: true },
};
case 'choamInflation':
faction('choam');
requireTerminal(
g.phase === 8 &&
g.inflationAttemptTurn === g.turn &&
!g.inflation &&
!g.inflationUsed &&
(response.intent === 'double' || response.intent === 'cancel'),
'Canceled Inflation needs its current unused token and declared side.',
);
return { kind: response.kind, owner: owner.id };
case 'stormPeek':
faction('fremen');
requireTerminal(
g.advanced &&
g.phase === 1 &&
positive(g.stormCard) &&
g.stormCard <= 6,
'Canceled storm knowledge needs its next-storm card after Storm.',
);
return { kind: response.kind, owner: owner.id, known: false };
case 'atreidesSpice':
faction('atreides');
requireTerminal(
!homeworldMovementForesightBlock(g, owner.id),
'Low-population Caladan has no Movement foresight opportunity to cancel.',
);
requireTerminal(
g.phase === 5 && Array.isArray(g.spiceDeck) && g.spiceDeck.length > 0,
'Canceled spice knowledge needs its existing lookahead opportunity.',
);
return { kind: response.kind, owner: owner.id, known: false };
case 'atreidesAuction': {
faction('atreides');
const a = g.auction;
requireTerminal(
g.phase === 3 &&
a &&
Array.isArray(a.cards) &&
nonnegative(a.index) &&
a.index < a.cards.length &&
object(a.cards[a.index]) &&
typeof a.cards[a.index].id === 'string' &&
!!a.cards[a.index].id,
'Canceled auction knowledge needs its existing normal auction card.',
);
return { kind: response.kind, owner: owner.id, known: false };
}
case 'faceDancerReplacement':
faction('tleilaxu');
requireTerminal(
g.phase === 8 &&
owner.faceDancerReplacedTurn === g.turn &&
typeof response.intent === 'string' &&
!!response.intent &&
Array.isArray(owner.faceDancers) &&
owner.faceDancers.every(
(d) =>
object(d) &&
typeof d.leader === 'string' &&
typeof d.revealed === 'boolean',
),
'Canceled Face Dancer replacement needs its declared attempt and readable pool.',
);
// The runtime computes an index even when canceled. An absent or revealed
// original candidate is harmless because cancellation replaces nothing.
return { kind: response.kind, owner: owner.id };
case 'advisor': {
faction('beneGesserit');
const destination = splitLocation(
response.location ?? 'polar_sink:0',
).territory;
const advisorAmount = response.amount === undefined ? 1 : response.amount;
requireTerminal(
positive(advisorAmount) &&
advisorAmount <=
homeworldSpiritualAdvisorAllowance(g, owner.id, destination),
'This spiritual advisor amount is unavailable at the current Homeworld population.',
);
if (response.advisorResume === 'ambassador') {
try {
const { order } = validateGuildAmbassadorArrivalContext(
g,
response.advisorAmbassadorEvent,
'advisor',
);
const key = response.location;
requireTerminal(
typeof key === 'string',
'The canceled Ambassador accompaniment needs its selected destination.',
);
const to = splitLocation(key);
requireTerminal(
owner.id !== order.player &&
g.players.find((p) => p.id === order.player)?.faction !==
'fremen' &&
location(to.territory, to.sector) === key &&
validLocation(to.territory, to.sector) &&
(to.territory === order.territory || key === 'polar_sink:0') &&
(g.advanced || key === 'polar_sink:0') &&
response.advisorFollowup === undefined &&
response.advisorRemaining === undefined,
'The canceled accompaniment does not match its Guild Ambassador shipment.',
);
return { kind: 'advisor', owner: owner.id, successor: 'ambassador' };
} catch (error) {
if (error instanceof GuildAmbassadorContinuationError)
throw new TerminalCancellationError(error.message);
throw error;
}
}
requireTerminal(
response.advisorAmbassadorEvent === undefined,
'Only Ambassador accompaniment may carry its parent event.',
);
const key = response.location ?? 'polar_sink:0';
requireTerminal(
g.phase === 5 && typeof key === 'string',
'Canceled advisor shipment needs its movement-phase destination.',
);
const to = splitLocation(key);
requireTerminal(
location(to.territory, to.sector) === key &&
validLocation(to.territory, to.sector) &&
gameTerritories(g).some((t) => t.id === to.territory) &&
(g.advanced || key === 'polar_sink:0'),
'Canceled advisor shipment has an invalid declared destination.',
);
return { kind: response.kind, owner: owner.id };
}
default:
return null;
}
}