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; export type RevivalArrivalDetails = Pick; export type RevivalArrivalContext = Pick; type HistoricalContext = Pick & { players: readonly Pick[]; revivalPlayer: string; }; const record = (value: unknown): value is Record => !!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 { 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; }