245 lines
15 KiB
TypeScript
245 lines
15 KiB
TypeScript
import type { Game } from './engine';
|
|
import { location, splitLocation, validLocation } from './board';
|
|
import { HomeworldCustodyError } from './homeworld-custody';
|
|
import { validateHomeworldVictoryReturn } from './homeworld-victory-return';
|
|
import type { FactionId } from './catalog';
|
|
import type { SpiritualAdvisorSource } from './spiritual-advisor';
|
|
import type { RevivalTerrorPacket } from './revival-terror-entry';
|
|
|
|
export type CaladanArrivalContext = Pick<Game, 'status' | 'turn' | 'phase' | 'players' |
|
|
'homeworlds' | 'lastBattleContext' | 'homeworldVictoryReinforcement'>;
|
|
export type CaladanArrivalDetails = {
|
|
victoryEvent?: string;
|
|
entrant: string;
|
|
territory: string;
|
|
sector: number;
|
|
amount: number;
|
|
elite: number;
|
|
cause: string;
|
|
};
|
|
|
|
/** Validate the committed original addition, not the army after a reaction.
|
|
* Atomics, alliances and other accepted effects may already have changed it. */
|
|
export function validateCaladanArrivalSource(g: CaladanArrivalContext, entry: CaladanArrivalDetails): void {
|
|
validateHomeworldVictoryReturn(g);
|
|
const frame = g.homeworldVictoryReinforcement;
|
|
if (g.status !== 'playing' || g.phase !== 6 || !frame || frame.stage !== 'arrival' ||
|
|
frame.turn !== g.turn || frame.phase !== g.phase || entry.victoryEvent !== frame.event ||
|
|
entry.entrant !== frame.player || entry.cause !== 'caladanReinforcement' ||
|
|
entry.amount !== 1 || entry.elite !== 0 || !validLocation(entry.territory, entry.sector) ||
|
|
frame.destination !== location(entry.territory, entry.sector))
|
|
throw new HomeworldCustodyError('The Caladan arrival has lost its original committed victory reinforcement.');
|
|
}
|
|
|
|
export function caladanOwnsTerrorEntry(g: CaladanArrivalContext, entry: CaladanArrivalDetails): boolean {
|
|
try { validateCaladanArrivalSource(g, entry); return true; }
|
|
catch (error) {
|
|
if (error instanceof HomeworldCustodyError) return false;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export type CaladanEntryParent = {
|
|
event: string;
|
|
terror: RevivalTerrorPacket;
|
|
spiritual?: SpiritualAdvisorSource;
|
|
};
|
|
export type CaladanSneakTrigger = Omit<CaladanArrivalDetails, 'victoryEvent'> & {
|
|
victoryEvent?: string;
|
|
caladanArrival?: CaladanEntryParent;
|
|
};
|
|
/** Engine-only consistency history. No raw Terror signature, candidate list,
|
|
* current force snapshot or private deck is part of this receipt. */
|
|
export type CaladanSneakReceipt = {
|
|
version: 1;
|
|
victoryEvent: string;
|
|
turn: number;
|
|
phase: 6;
|
|
token: string;
|
|
trigger: CaladanSneakTrigger;
|
|
packet: RevivalTerrorPacket;
|
|
signature: string;
|
|
};
|
|
export type CaladanTerrorHistoryContext = {
|
|
turn: number;
|
|
phase: number;
|
|
players: readonly { id: string; faction: FactionId }[];
|
|
victoryPlayer: string;
|
|
terrorArrivals: readonly CaladanSneakReceipt[];
|
|
};
|
|
type CaladanTerrorEntry = NonNullable<Game['pendingTerrorEntry']> & { caladanArrival?: CaladanEntryParent };
|
|
const record = (value: unknown): value is Record<string, unknown> =>
|
|
!!value && typeof value === 'object' && !Array.isArray(value);
|
|
const text = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0;
|
|
const count = (value: unknown, maximum = 20): value is number =>
|
|
Number.isSafeInteger(value) && (value as number) >= 0 && (value as number) <= maximum;
|
|
function exact(value: unknown, names: readonly string[]): value is Record<string, unknown> {
|
|
return record(value) && Object.keys(value).length === names.length && names.every(key => Object.hasOwn(value, key));
|
|
}
|
|
function fail(): never {
|
|
throw new HomeworldCustodyError('The Caladan Sneak Attack lost its original committed arrival source.');
|
|
}
|
|
const uuid = (value: unknown): value is string => typeof value === 'string' &&
|
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
|
|
function validPacket(value: unknown): value is RevivalTerrorPacket {
|
|
return exact(value, ['event', 'owner', 'territory', 'sector', 'amount', 'elite']) && uuid(value.event) &&
|
|
text(value.owner) && text(value.territory) && count(value.sector, 18) &&
|
|
validLocation(value.territory, value.sector) && count(value.amount, 5) && value.amount > 0 && value.elite === 0;
|
|
}
|
|
function validSpiritual(value: unknown, event: string): value is SpiritualAdvisorSource {
|
|
return exact(value, ['event', 'owner', 'turn', 'phase', 'territory', 'sector', 'amount', 'victoryEvent']) &&
|
|
text(value.event) && text(value.owner) && Number.isSafeInteger(value.turn) && (value.turn as number) > 0 &&
|
|
value.phase === 6 && text(value.territory) && count(value.sector, 18) &&
|
|
validLocation(value.territory, value.sector) && (value.amount === 1 || value.amount === 2) && value.victoryEvent === event;
|
|
}
|
|
export function validCaladanEntryParent(value: unknown): value is CaladanEntryParent {
|
|
return record(value) && exact(value, Object.hasOwn(value, 'spiritual') ? ['event', 'terror', 'spiritual'] : ['event', 'terror']) &&
|
|
text(value.event) && validPacket(value.terror) &&
|
|
(!Object.hasOwn(value, 'spiritual') || validSpiritual(value.spiritual, value.event));
|
|
}
|
|
function packetTuple(packet: RevivalTerrorPacket) {
|
|
return [packet.event, packet.owner, packet.territory, packet.sector, packet.amount, packet.elite];
|
|
}
|
|
export function caladanEntryParentTuple(parent: CaladanEntryParent): unknown[] {
|
|
if (!validCaladanEntryParent(parent)) fail();
|
|
const source = parent.spiritual;
|
|
return ['caladanArrival', parent.event, source ? [source.event, source.owner, source.turn, source.phase,
|
|
source.territory, source.sector, source.amount, source.victoryEvent] : null, packetTuple(parent.terror)];
|
|
}
|
|
function triggerTuple(trigger: CaladanSneakTrigger) {
|
|
return [trigger.entrant, trigger.territory, trigger.sector, trigger.amount, trigger.elite, trigger.cause,
|
|
trigger.victoryEvent ?? null, trigger.caladanArrival ? caladanEntryParentTuple(trigger.caladanArrival) : null];
|
|
}
|
|
function receiptSignature(receipt: CaladanSneakReceipt): string {
|
|
return JSON.stringify(['caladanSneak', receipt.version, receipt.victoryEvent, receipt.turn, receipt.phase,
|
|
receipt.token, triggerTuple(receipt.trigger), packetTuple(receipt.packet)]);
|
|
}
|
|
function validTrigger(trigger: unknown): trigger is CaladanSneakTrigger {
|
|
if (!record(trigger)) return false;
|
|
const root = Object.hasOwn(trigger, 'victoryEvent'), child = Object.hasOwn(trigger, 'caladanArrival');
|
|
return root !== child && exact(trigger, ['entrant', 'territory', 'sector', 'amount', 'elite', 'cause',
|
|
root ? 'victoryEvent' : 'caladanArrival']) && text(trigger.entrant) && text(trigger.territory) &&
|
|
count(trigger.sector, 18) && validLocation(trigger.territory, trigger.sector) &&
|
|
count(trigger.amount) && trigger.amount > 0 && count(trigger.elite, trigger.amount) && text(trigger.cause) &&
|
|
(root ? text(trigger.victoryEvent) : validCaladanEntryParent(trigger.caladanArrival));
|
|
}
|
|
function childMatches(context: CaladanTerrorHistoryContext, parent: CaladanEntryParent, details: CaladanArrivalDetails) {
|
|
if (parent.spiritual) {
|
|
const source = parent.spiritual;
|
|
return context.players.filter(player => player.id === source.owner && player.faction === 'beneGesserit').length === 1 &&
|
|
source.owner !== context.victoryPlayer && source.turn === context.turn && source.phase === context.phase &&
|
|
(source.territory === parent.terror.territory || source.territory === 'polar_sink') &&
|
|
details.entrant === source.owner && details.cause === 'advisor' && details.territory === source.territory &&
|
|
details.sector === source.sector && details.amount === source.amount && details.elite === 0;
|
|
}
|
|
const packet = parent.terror;
|
|
return details.entrant === packet.owner && details.cause === 'shipment' && details.territory === packet.territory &&
|
|
details.sector === packet.sector && details.amount === packet.amount && details.elite === packet.elite;
|
|
}
|
|
|
|
function validatePacketHistory(context: CaladanTerrorHistoryContext, packet: RevivalTerrorPacket,
|
|
destination: string, victoryEvent: string, seen: Set<string>): void {
|
|
if (!validPacket(packet) || seen.size > 16 || seen.has(packet.event) || context.phase !== 6 ||
|
|
!Number.isSafeInteger(context.turn) || context.turn < 1 || !Array.isArray(context.terrorArrivals) ||
|
|
context.players.filter(player => player.id === context.victoryPlayer && player.faction === 'atreides').length !== 1 ||
|
|
context.players.filter(player => player.id === packet.owner && player.faction === 'moritani').length !== 1) fail();
|
|
const point = splitLocation(destination);
|
|
if (!text(victoryEvent) || destination !== location(point.territory, point.sector) ||
|
|
!validLocation(point.territory, point.sector)) fail();
|
|
const matches = context.terrorArrivals.filter(receipt => record(receipt) && record(receipt.packet) && receipt.packet.event === packet.event);
|
|
if (matches.length !== 1) fail();
|
|
const receipt = matches[0];
|
|
if (!exact(receipt, ['version', 'victoryEvent', 'turn', 'phase', 'token', 'trigger', 'packet', 'signature']) ||
|
|
receipt.version !== 1 || receipt.victoryEvent !== victoryEvent || receipt.turn !== context.turn || receipt.phase !== 6 ||
|
|
!text(receipt.token) || !validTrigger(receipt.trigger) || !validPacket(receipt.packet) ||
|
|
JSON.stringify(packetTuple(receipt.packet)) !== JSON.stringify(packetTuple(packet)) ||
|
|
receipt.signature !== receiptSignature(receipt as CaladanSneakReceipt) || receipt.trigger.territory !== packet.territory ||
|
|
receipt.trigger.sector !== packet.sector) fail();
|
|
seen.add(packet.event);
|
|
const trigger = receipt.trigger;
|
|
if (trigger.victoryEvent !== undefined) {
|
|
if (trigger.victoryEvent !== victoryEvent || trigger.entrant !== context.victoryPlayer ||
|
|
trigger.territory !== point.territory || trigger.sector !== point.sector ||
|
|
trigger.amount !== 1 || trigger.elite !== 0 || trigger.cause !== 'caladanReinforcement') fail();
|
|
} else {
|
|
const parent = trigger.caladanArrival!;
|
|
if (parent.event !== victoryEvent || !childMatches(context, parent, trigger)) fail();
|
|
validatePacketHistory(context, parent.terror, destination, victoryEvent, seen);
|
|
}
|
|
}
|
|
|
|
/** Resolve an opaque public packet through original sanitized engine history;
|
|
* no current reserves, inhabitants or removed-token custody are re-read. */
|
|
export function validateCaladanTerrorPacket(context: CaladanTerrorHistoryContext, packet: RevivalTerrorPacket,
|
|
destination: string, victoryEvent: string): void {
|
|
validatePacketHistory(context, packet, destination, victoryEvent, new Set());
|
|
}
|
|
|
|
export function validateCaladanSneakHistory(g: Pick<Game, 'turn' | 'players' | 'homeworldVictoryReinforcement'>): void {
|
|
const frame = g.homeworldVictoryReinforcement;
|
|
if (frame?.terrorArrivals === undefined) return;
|
|
if (!Array.isArray(frame.terrorArrivals) || frame.turn > g.turn || frame.phase !== 6 ||
|
|
!['arrival', 'complete'].includes(frame.stage) || !text(frame.destination) || frame.destination === 'decline') fail();
|
|
const seen = new Set<string>();
|
|
const context: CaladanTerrorHistoryContext = { turn: frame.turn, phase: frame.phase, players: g.players,
|
|
victoryPlayer: frame.player, terrorArrivals: frame.terrorArrivals };
|
|
for (const receipt of frame.terrorArrivals) {
|
|
if (!record(receipt) || !validPacket(receipt.packet) || seen.has(receipt.packet.event)) fail();
|
|
seen.add(receipt.packet.event);
|
|
validateCaladanTerrorPacket(context, receipt.packet, frame.destination, frame.event);
|
|
}
|
|
}
|
|
|
|
/** A new entry belongs to the committed Sneak packet, optionally followed by
|
|
* its own actual Spiritual packet; the original Caladan force is not reused. */
|
|
export function validateCaladanChildArrival(g: CaladanArrivalContext, parent: CaladanEntryParent,
|
|
details: CaladanArrivalDetails): void {
|
|
validateHomeworldVictoryReturn(g);
|
|
const frame = g.homeworldVictoryReinforcement;
|
|
if (!validCaladanEntryParent(parent) || g.status !== 'playing' || g.phase !== 6 || !frame || frame.stage !== 'arrival' ||
|
|
frame.turn !== g.turn || parent.event !== frame.event || !text(frame.destination) ||
|
|
details.victoryEvent !== undefined || !count(details.amount) || details.amount < 1 || !count(details.elite, details.amount)) fail();
|
|
const context: CaladanTerrorHistoryContext = { turn: frame.turn, phase: frame.phase, players: g.players,
|
|
victoryPlayer: frame.player, terrorArrivals: frame.terrorArrivals ?? [] };
|
|
validateCaladanTerrorPacket(context, parent.terror, frame.destination, parent.event);
|
|
if (!childMatches(context, parent, details)) fail();
|
|
}
|
|
|
|
function sanitizedTrigger(entry: CaladanTerrorEntry): CaladanSneakTrigger {
|
|
const trigger: CaladanSneakTrigger = { entrant: entry.entrant, territory: entry.territory, sector: entry.sector,
|
|
amount: entry.amount, elite: entry.elite, cause: entry.cause,
|
|
...(entry.victoryEvent === undefined ? {} : { victoryEvent: entry.victoryEvent }),
|
|
...(entry.caladanArrival === undefined ? {} : { caladanArrival: structuredClone(entry.caladanArrival) }) };
|
|
if (!validTrigger(trigger)) fail();
|
|
return trigger;
|
|
}
|
|
|
|
/** Quote before native debit; the engine appends the receipt only after the
|
|
* actual positive transfer, then queues this packet behind the original overlap. */
|
|
export function createCaladanSneakReceipt(g: Game, entry: CaladanTerrorEntry, owner: string, amount: number,
|
|
eventUUID: string): { packet: RevivalTerrorPacket; receipt: CaladanSneakReceipt } {
|
|
const pending = g.pendingTerrorEntry as CaladanTerrorEntry | null | undefined;
|
|
const token = g.moritaniTerror?.tokens.find(candidate => candidate.id === entry.token);
|
|
if (g.status !== 'playing' || g.phase !== 6 || !pending || pending.stage !== 'sneakAttack' ||
|
|
entry.stage !== 'sneakAttack' || pending.token !== entry.token || entry.turn !== g.turn || entry.phase !== 6 ||
|
|
pending.turn !== g.turn || pending.phase !== 6 || entry.revivalArrival !== undefined ||
|
|
(g.decision && (g.decision.kind !== 'moritaniTerror' || g.decision.player !== owner)) ||
|
|
g.players.filter(player => player.id === owner && player.faction === 'moritani').length !== 1 ||
|
|
!token || token.kind !== 'sneakAttack' || token.status !== 'removed' || token.location !== null ||
|
|
!count(amount, 5) || amount < 1 || !uuid(eventUUID)) fail();
|
|
const trigger = sanitizedTrigger(entry);
|
|
if (JSON.stringify(triggerTuple(trigger)) !== JSON.stringify(triggerTuple(sanitizedTrigger(pending)))) fail();
|
|
if (entry.victoryEvent !== undefined) validateCaladanArrivalSource(g, entry);
|
|
else if (entry.caladanArrival) validateCaladanChildArrival(g, entry.caladanArrival, entry);
|
|
else fail();
|
|
const frame = g.homeworldVictoryReinforcement!;
|
|
validateCaladanSneakHistory(g);
|
|
if (frame.terrorArrivals?.some(receipt => receipt.packet.event === eventUUID)) fail();
|
|
const packet: RevivalTerrorPacket = { event: eventUUID, owner, territory: entry.territory,
|
|
sector: entry.sector, amount, elite: 0 };
|
|
const receipt: CaladanSneakReceipt = { version: 1, victoryEvent: frame.event, turn: g.turn, phase: 6,
|
|
token: token.id, trigger, packet: { ...packet }, signature: '' };
|
|
receipt.signature = receiptSignature(receipt);
|
|
return { packet, receipt };
|
|
}
|