348 lines
12 KiB
TypeScript
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;
|
|
}
|
|
}
|