Files
Dune/game/revival-terror-entry.ts

186 lines
11 KiB
TypeScript

import type { Game } from './engine';
import { gameTerritories, location, splitLocation, validLocation } from './board';
import { HomeworldCustodyError } from './homeworld-custody';
import { currentHomeworldRevivalArrival, currentHomeworldRevivalGroup, validateHomeworldRevivalReturn } from './homeworld-revival-return';
import type { SpiritualAdvisorSource } from './spiritual-advisor';
export type RevivalTerrorPacket = {
event: string;
owner: string;
territory: string;
sector: number;
amount: number;
elite: number;
};
export type RevivalEntryParent = {
event: string;
spiritual?: SpiritualAdvisorSource;
terror?: RevivalTerrorPacket;
};
type TerrorEntry = NonNullable<Game['pendingTerrorEntry']>;
export type RevivalArrivalDetails = Pick<TerrorEntry, 'entrant' | 'territory' | 'sector' | 'amount' | 'elite' | 'cause'>;
export type RevivalArrivalContext = Pick<Game, 'status' | 'advanced' | 'turn' | 'phase' | 'players' |
'homeworldRevivalReturn' | 'homeworldRevivalProgress' | 'homeworlds' |
'pendingAmbassador' | 'pendingTreacheryDiscard' | 'mobileStronghold' | 'discoveries'>;
type HistoricalContext = Pick<Game, 'turn' | 'phase'> & {
players: readonly Pick<Game['players'][number], 'id' | 'faction'>[];
revivalPlayer: string;
};
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 revival entry lost its original returned group or committed child source.');
}
function capturedTerror(event: unknown): unknown[] | null {
if (!text(event)) return null;
try {
const value: unknown = JSON.parse(event);
if (!Array.isArray(value) || value.length < 11 ||
!(text(value[0]) || Array.isArray(value[0]) && value[0][0] === 'stack' &&
Array.isArray(value[0][1]) && value[0][1].length >= 2 && value[0][1].every(text) &&
(value[0][2] === null || text(value[0][2]))) ||
!text(value[1]) || !text(value[2]) || !count(value[3], 18) ||
!validLocation(value[2], value[3]) || !count(value[4]) || !count(value[5], value[4]) ||
!text(value[6]) || !Number.isSafeInteger(value[7]) || value[7] < 1 ||
!count(value[8], 8) || !['none', 'wormRide', 'ambassador'].includes(value[9]) ||
!(value[10] === null || text(value[10]))) return null;
return value;
} catch { return null; }
}
/** Nonrecursive historical syntax, usable by the original Ambassador history. */
export function validRevivalTerrorPacket(value: unknown): value is RevivalTerrorPacket {
return exact(value, ['event', 'owner', 'territory', 'sector', 'amount', 'elite']) &&
capturedTerror(value.event) !== null && 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 capturedParent(value: unknown): RevivalEntryParent | null {
if (!Array.isArray(value) || value.length !== 4 || value[0] !== 'revivalArrival' || !text(value[1])) return null;
const spiritual = value[2], terror = value[3];
if (spiritual !== null && (!Array.isArray(spiritual) || spiritual.length !== 8) ||
terror !== null && (!Array.isArray(terror) || terror.length !== 6)) return null;
const parent = { event: value[1],
...(spiritual === null ? {} : { spiritual: { event: spiritual[0], owner: spiritual[1], turn: spiritual[2],
phase: spiritual[3], territory: spiritual[4], sector: spiritual[5], amount: spiritual[6], revivalEvent: spiritual[7] } }),
...(terror === null ? {} : { terror: { event: terror[0], owner: terror[1], territory: terror[2],
sector: terror[3], amount: terror[4], elite: terror[5] } }),
};
return validRevivalEntryParent(parent) ? parent : null;
}
/** Validate the captured original clock/territory without asking the original
* Terror action to exist again or requiring its later victims to survive. */
export function validateRevivalTerrorPacket(
context: HistoricalContext,
packet: RevivalTerrorPacket,
destination: string,
revivalEvent: string,
depth = 0,
): void {
if (depth > 16 || !validRevivalTerrorPacket(packet) || !text(destination) || !text(revivalEvent) ||
!context.players.some(player => player.id === context.revivalPlayer && ['fremen', 'tleilaxu'].includes(player.faction))) fail();
const point = splitLocation(destination), original = capturedTerror(packet.event)!;
const parent = capturedParent(original.at(-1));
if (destination !== location(point.territory, point.sector) || !validLocation(point.territory, point.sector) ||
!parent || parent.event !== revivalEvent || original[2] !== packet.territory || original[3] !== packet.sector ||
original[7] !== context.turn || original[8] !== context.phase ||
context.players.filter(player => player.id === packet.owner && player.faction === 'moritani').length !== 1 ||
!context.players.some(player => player.id === original[1] && player.faction !== 'moritani')) fail();
if (parent.terror) validateRevivalTerrorPacket(context, parent.terror, destination, revivalEvent, depth + 1);
if (parent.spiritual) {
const source = parent.spiritual, origin = parent.terror?.territory ?? point.territory;
if ((!parent.terror && !context.players.some(player => player.id === context.revivalPlayer && player.faction === 'tleilaxu')) ||
source.turn !== context.turn || source.phase !== context.phase ||
context.players.filter(player => player.id === source.owner && player.faction === 'beneGesserit').length !== 1 ||
(source.territory !== origin && source.territory !== 'polar_sink') ||
original[1] !== source.owner || original[2] !== source.territory || original[3] !== source.sector ||
original[4] !== source.amount || original[5] !== 0 || original[6] !== 'advisor') fail();
} else if (parent.terror || original[1] !== context.revivalPlayer || original[2] !== point.territory || original[3] !== point.sector ||
original[6] !== 'homeworldRevival') fail();
}
function validSpiritual(value: unknown, event: string): value is SpiritualAdvisorSource {
return exact(value, ['event', 'owner', 'turn', 'phase', 'territory', 'sector', 'amount', 'revivalEvent']) &&
text(value.event) && text(value.owner) && Number.isSafeInteger(value.turn) && (value.turn as number) > 0 &&
value.phase === 4 && text(value.territory) && count(value.sector, 18) &&
validLocation(value.territory, value.sector) && (value.amount === 1 || value.amount === 2) && value.revivalEvent === event;
}
/** Basic public metadata shape only. Current original-return validation belongs
* to the entry caller; recorded history can use this without recursion. */
export function validRevivalEntryParent(value: unknown): value is RevivalEntryParent {
return record(value) && Object.hasOwn(value, 'event') && text(value.event) &&
Object.keys(value).every(key => ['event', 'spiritual', 'terror'].includes(key)) &&
(!Object.hasOwn(value, 'spiritual') || validSpiritual(value.spiritual, value.event)) &&
(!Object.hasOwn(value, 'terror') || validRevivalTerrorPacket(value.terror));
}
/** Conditional public signature suffix; old entries never acquire these bytes. */
export function revivalEntryParentTuple(parent: RevivalEntryParent): unknown[] {
if (!validRevivalEntryParent(parent)) fail();
const source = parent.spiritual, terror = parent.terror;
return ['revivalArrival', parent.event,
source ? [source.event, source.owner, source.turn, source.phase, source.territory, source.sector, source.amount, source.revivalEvent] : null,
terror ? [terror.event, terror.owner, terror.territory, terror.sector, terror.amount, terror.elite] : null];
}
/** The public packet is historical. Accepted Terror may already have changed
* pieces, alliances or optional powers; do not re-run a live shipment quote. */
export function validateRevivalArrivalSource(g: RevivalArrivalContext, parent: RevivalEntryParent, details: RevivalArrivalDetails): void {
if (!validRevivalEntryParent(parent)) fail();
const frame = g.homeworldRevivalReturn;
const arrival = frame ? currentHomeworldRevivalArrival(frame) : null;
const group = frame ? currentHomeworldRevivalGroup(frame) : null;
if (g.status !== 'playing' || !frame || !arrival || !group || frame.stage !== 'arrival' ||
arrival.stage !== 'arrival' || arrival.event !== parent.event || frame.turn !== g.turn || frame.phase !== g.phase ||
!text(arrival.destination)) fail();
const continuation = g.pendingTreacheryDiscard?.continuation;
const context = !g.pendingAmbassador && continuation?.kind === 'ambassador'
? { ...g, pendingAmbassador: continuation.entry } : g;
validateHomeworldRevivalReturn(context, frame);
const original = splitLocation(arrival.destination);
if (arrival.destination !== location(original.territory, original.sector) ||
!gameTerritories(g).some(place => place.id === original.territory && place.sectors.includes(original.sector)) ||
!gameTerritories(g).some(place => place.id === details.territory && place.sectors.includes(details.sector)) ||
!count(details.amount) || details.amount < 1 || !count(details.elite, details.amount)) fail();
if (parent.terror) validateRevivalTerrorPacket({ ...g, revivalPlayer: frame.player }, parent.terror, arrival.destination, arrival.event);
if (parent.spiritual) {
const source = parent.spiritual;
const origin = parent.terror?.territory ?? original.territory;
if ((!parent.terror && !g.players.some(player => player.id === frame.player && player.faction === 'tleilaxu')) ||
g.players.filter(player => player.id === source.owner && player.faction === 'beneGesserit').length !== 1 ||
source.turn !== g.turn || source.phase !== g.phase || source.owner === frame.player ||
(source.territory !== origin && 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) fail();
} else if (parent.terror) {
const packet = parent.terror;
if (details.entrant !== packet.owner || details.cause !== 'shipment' || details.territory !== packet.territory ||
details.sector !== packet.sector || details.amount !== packet.amount || details.elite !== packet.elite) fail();
} else if (details.entrant !== frame.player || details.cause !== 'homeworldRevival' ||
details.territory !== original.territory || details.sector !== original.sector ||
details.amount !== group.amount || details.elite !== group.elite) fail();
}
export function validateRevivalTerrorEntry(g: RevivalArrivalContext, entry: TerrorEntry): void {
const parent = entry.revivalArrival;
if (parent === undefined) return;
if (entry.turn !== g.turn || entry.phase !== g.phase) fail();
validateRevivalArrivalSource(g, parent, entry);
}
export function revivalOwnsTerrorEntry(g: RevivalArrivalContext, entry: TerrorEntry): boolean {
if (entry.revivalArrival === undefined) return false;
validateRevivalTerrorEntry(g, entry);
return true;
}