Preserve Caladan Sneak Attack child arrivals
This commit is contained in:
1 parent
2e4aba9630
commit
f0ebdf9c87
16 files changed
+1065
-44
No files matched your search
+17
-3
@@ -1,5 +1,6 @@
|
||||
import type { Game } from './engine';
|
||||
import { caladanOwnsTerrorEntry } from './caladan-arrival';
|
||||
import { caladanOwnsTerrorEntry, validateCaladanChildArrival, caladanEntryParentTuple,
|
||||
validCaladanEntryParent, type CaladanEntryParent } from './caladan-arrival';
|
||||
import { currentHomeworldRevivalArrival } from './homeworld-revival-return';
|
||||
import { validateRevivalArrivalSource, revivalOwnsTerrorEntry, validRevivalEntryParent,
|
||||
revivalEntryParentTuple, type RevivalEntryParent } from './revival-terror-entry';
|
||||
@@ -14,6 +15,10 @@ function sameRevivalSource(left: RevivalEntryParent | undefined, right: RevivalE
|
||||
return validRevivalEntryParent(left) && validRevivalEntryParent(right) &&
|
||||
JSON.stringify(revivalEntryParentTuple(left)) === JSON.stringify(revivalEntryParentTuple(right));
|
||||
}
|
||||
function sameCaladanSource(left: CaladanEntryParent | undefined, right: CaladanEntryParent | undefined) {
|
||||
return validCaladanEntryParent(left) && validCaladanEntryParent(right) &&
|
||||
JSON.stringify(caladanEntryParentTuple(left)) === JSON.stringify(caladanEntryParentTuple(right));
|
||||
}
|
||||
|
||||
/** The immutable trigger, not later owner choices or child stages. */
|
||||
export function arrivalOverlapSignature(record: Overlap): string {
|
||||
@@ -22,7 +27,8 @@ export function arrivalOverlapSignature(record: Overlap): string {
|
||||
record.resume, record.ecaz, record.moritani, record.first,
|
||||
record.ambassadorToken, record.terrorTokens,
|
||||
...(record.revivalArrival === undefined ? [] : [revivalEntryParentTuple(record.revivalArrival)]),
|
||||
...(record.victoryEvent === undefined ? [] : [['caladanVictory', record.victoryEvent]])]);
|
||||
...(record.victoryEvent === undefined ? [] : [['caladanVictory', record.victoryEvent]]),
|
||||
...(record.caladanArrival === undefined ? [] : [caladanEntryParentTuple(record.caladanArrival)])]);
|
||||
}
|
||||
|
||||
/** A fresh discard belongs to the active owner of an ordinary or bound revived entry.
|
||||
@@ -33,7 +39,10 @@ function ordinaryDiscardOverlap(g: DiscardContext, owner: Overlap['first']): Ove
|
||||
overlap.turn !== g.turn || overlap.phase !== g.phase ||
|
||||
overlap.resume !== 'none' ||
|
||||
!['first', 'second'].includes(overlap.stage)) return null;
|
||||
if (overlap.revivalArrival !== undefined) {
|
||||
if (overlap.caladanArrival !== undefined) {
|
||||
try { validateCaladanChildArrival(g,overlap.caladanArrival,overlap); }
|
||||
catch { return null; }
|
||||
} else if (overlap.revivalArrival !== undefined) {
|
||||
try {
|
||||
validateRevivalArrivalSource(g, overlap.revivalArrival, overlap);
|
||||
} catch { return null; }
|
||||
@@ -54,12 +63,16 @@ function ordinaryDiscardOverlap(g: DiscardContext, owner: Overlap['first']): Ove
|
||||
export function overlapOwnsAmbassadorDiscard(g: DiscardContext, entry: AmbassadorEntry): boolean {
|
||||
const overlap = ordinaryDiscardOverlap({ ...g, pendingAmbassador: entry }, 'ambassador');
|
||||
const arrival = currentHomeworldRevivalArrival(g.homeworldRevivalReturn)?.ambassadors?.find(record => record.event === entry.event);
|
||||
const caladan = g.homeworldVictoryReinforcement?.ambassadors?.find(record => record.event === entry.event);
|
||||
return !!overlap && entry.stage === 'cards' && entry.resume === 'none' &&
|
||||
entry.event === overlap.ambassadorEvent && entry.owner === overlap.ecaz &&
|
||||
entry.token === overlap.ambassadorToken && entry.entrant === overlap.entrant &&
|
||||
entry.territory === overlap.territory && entry.sector === overlap.sector &&
|
||||
entry.arrivalCause === overlap.cause && entry.turn === overlap.turn && entry.phase === overlap.phase &&
|
||||
(overlap.victoryEvent === undefined || entry.victoryEvent === overlap.victoryEvent) &&
|
||||
(overlap.caladanArrival === undefined || entry.victoryEvent === overlap.caladanArrival.event &&
|
||||
!!caladan?.terror && sameCaladanSource(overlap.caladanArrival,{event:entry.victoryEvent,
|
||||
terror:caladan.terror,...(caladan.spiritual ? {spiritual:caladan.spiritual} : {})})) &&
|
||||
(overlap.revivalArrival === undefined || entry.revivalEvent === overlap.revivalArrival.event &&
|
||||
!!arrival && sameRevivalSource(overlap.revivalArrival, { event: overlap.revivalArrival.event,
|
||||
...(arrival.spiritual === undefined ? {} : { spiritual: arrival.spiritual }),
|
||||
@@ -74,6 +87,7 @@ export function overlapOwnsTerrorDiscard(g: DiscardContext, entry: TerrorEntry,
|
||||
entry.amount === overlap.amount && entry.elite === overlap.elite &&
|
||||
entry.cause === overlap.cause && entry.turn === overlap.turn && entry.phase === overlap.phase &&
|
||||
(overlap.victoryEvent === undefined || entry.victoryEvent === overlap.victoryEvent) &&
|
||||
(overlap.caladanArrival === undefined || sameCaladanSource(overlap.caladanArrival,entry.caladanArrival)) &&
|
||||
(overlap.revivalArrival === undefined ||
|
||||
sameRevivalSource(overlap.revivalArrival, entry.revivalArrival) && revivalOwnsTerrorEntry(g, entry));
|
||||
}
|
||||
|
||||
+208
-1
@@ -1,7 +1,10 @@
|
||||
import type { Game } from './engine';
|
||||
import { location, validLocation } from './board';
|
||||
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'>;
|
||||
@@ -35,3 +38,207 @@ export function caladanOwnsTerrorEntry(g: CaladanArrivalContext, entry: CaladanA
|
||||
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 };
|
||||
}
|
||||
+99
-16
@@ -191,7 +191,8 @@ import { ambassadorPhaseAllowed } from './ambassador-phase';
|
||||
import { makeHomeworldVictoryReturn, validateHomeworldVictoryReturn, homeworldVictoryOfferSignature,
|
||||
homeworldVictoryObligationSignature, type HomeworldVictoryReturn, type HomeworldVictoryObligation } from './homeworld-victory-return';
|
||||
import { quoteHomeworldVictoryReinforcement, quoteHomeworldVictoryReinforcementDestination } from './homeworld-victory-reinforcement';
|
||||
import { validateCaladanArrivalSource } from './caladan-arrival';
|
||||
import { validateCaladanArrivalSource, validateCaladanChildArrival, createCaladanSneakReceipt,
|
||||
validateCaladanSneakHistory, type CaladanEntryParent } from './caladan-arrival';
|
||||
import { homeworldArrivalSignature, appendHomeworldArrivalAmbassador, completeHomeworldArrivalAmbassador, validateHomeworldArrival } from './homeworld-arrival';
|
||||
import {
|
||||
quoteHomeworldRevivalDeployment,
|
||||
@@ -1101,6 +1102,7 @@ export type Decision =
|
||||
| { kind: 'nexusEmperorAmbassadorPurchase'; player: string; event: string }
|
||||
| {
|
||||
kind: 'advisor';
|
||||
caladanArrival?: CaladanEntryParent;
|
||||
player: string;
|
||||
shipment: string;
|
||||
destination?: string;
|
||||
@@ -1110,6 +1112,7 @@ export type Decision =
|
||||
}
|
||||
| {
|
||||
kind: 'intrusion';
|
||||
caladanArrival?: CaladanEntryParent;
|
||||
victoryEvent?: string;
|
||||
player: string;
|
||||
territory: string;
|
||||
@@ -1118,7 +1121,7 @@ export type Decision =
|
||||
ambassadorEvent?: string;
|
||||
revivalEvent?: string;
|
||||
revivalTerror?: RevivalEntryParent['terror'];
|
||||
followup?: { shipment: string; destination: string; revivalEvent?: string; revivalTerror?: RevivalEntryParent['terror'] };
|
||||
followup?: { shipment: string; destination: string; caladanArrival?: CaladanEntryParent; revivalEvent?: string; revivalTerror?: RevivalEntryParent['terror'] };
|
||||
}
|
||||
| { kind: 'advisorBattle'; player: string; territories: string[] }
|
||||
| {
|
||||
@@ -1195,11 +1198,12 @@ export type ResponseWindow = {
|
||||
advisorGatherMove?: { turn: number; move: number; order: MovementOrder };
|
||||
discoveryEntry?: DiscoveryEntryArrivalChild;
|
||||
advisorAmbassadorEvent?: string;
|
||||
advisorCaladanArrival?: CaladanEntryParent;
|
||||
advisorVictoryEvent?: string;
|
||||
advisorRevivalEvent?: string;
|
||||
advisorRevivalTerror?: RevivalEntryParent['terror'];
|
||||
advisorRemaining?: string[];
|
||||
advisorFollowup?: { shipment: string; destination: string; revivalEvent?: string; revivalTerror?: RevivalEntryParent['terror'] };
|
||||
advisorFollowup?: { shipment: string; destination: string; caladanArrival?: CaladanEntryParent; revivalEvent?: string; revivalTerror?: RevivalEntryParent['terror'] };
|
||||
amount?: number;
|
||||
elite?: number;
|
||||
recipient?: string;
|
||||
@@ -2050,6 +2054,7 @@ export type Game = {
|
||||
dukeAcquisitionTurn?: number;
|
||||
pendingTerrorEntry?: {
|
||||
victoryEvent?: string;
|
||||
caladanArrival?: CaladanEntryParent;
|
||||
revivalArrival?: RevivalEntryParent;
|
||||
/** Original public arrival, absent only in legacy saves. */
|
||||
entrySignature?: string;
|
||||
@@ -2107,13 +2112,16 @@ export type Game = {
|
||||
resume: 'none' | 'wormRide';
|
||||
turn: number;
|
||||
phase: number;
|
||||
caladanArrival?: CaladanEntryParent;
|
||||
revivalArrival?: RevivalEntryParent;
|
||||
/** A committed revival Sneak group waits for the original overlap before BG reactions. */
|
||||
revivalSneakAdvisor?: true;
|
||||
caladanSneakAdvisor?: true;
|
||||
}[] | null;
|
||||
/** One committed entry with two independently optional expansion reactions. */
|
||||
pendingArrivalOverlap?: {
|
||||
victoryEvent?: string;
|
||||
caladanArrival?: CaladanEntryParent;
|
||||
revivalArrival?: RevivalEntryParent;
|
||||
event: string;
|
||||
signature: string;
|
||||
@@ -21767,6 +21775,7 @@ function arrivalOverlapIntegrity(g: Game) {
|
||||
(continuation?.kind === 'ambassador' ? continuation.entry : null);
|
||||
const terror = g.pendingTerrorEntry ??
|
||||
(continuation?.kind === 'terrorDiscard' || continuation?.kind === 'terrorAtomics' ? continuation.entry : null);
|
||||
if (record.caladanArrival) homeworldRule(() => validateCaladanChildArrival(g,record.caladanArrival!,record));
|
||||
if (record.revivalArrival)
|
||||
homeworldRule(() => validateRevivalArrivalSource(g, record.revivalArrival!, record));
|
||||
if (record.victoryEvent !== undefined || record.cause === 'caladanReinforcement')
|
||||
@@ -21812,6 +21821,7 @@ function arrivalOverlapIntegrity(g: Game) {
|
||||
terror.turn === record.turn && terror.phase === record.phase &&
|
||||
terror.cause === record.cause &&
|
||||
JSON.stringify(terror.revivalArrival) === JSON.stringify(record.revivalArrival) &&
|
||||
JSON.stringify(terror.caladanArrival) === JSON.stringify(record.caladanArrival) &&
|
||||
record.terrorTokens.includes(terror.token)),
|
||||
'The saved Ambassador/Terror overlap lost its original ordered arrival.');
|
||||
}
|
||||
@@ -21820,6 +21830,7 @@ function openAmbassadorEntry(
|
||||
sector: number, resume: 'none' | 'wormRide',
|
||||
arrivalCause?: NonNullable<Game['pendingTerrorEntry']>['cause'],
|
||||
revivalArrival?: RevivalEntryParent,
|
||||
caladanArrival?: CaladanEntryParent,
|
||||
): boolean {
|
||||
g.pendingAmbassador = {
|
||||
...(arrivalCause ? { arrivalCause } : {}),
|
||||
@@ -21847,6 +21858,13 @@ function openAmbassadorEntry(
|
||||
g.pendingAmbassador.revivalEvent = frame.event;
|
||||
homeworldRule(() => appendHomeworldArrivalAmbassador(frame, g.pendingAmbassador!, undefined,
|
||||
revivalArrival.spiritual, revivalArrival.terror));
|
||||
} else if (caladanArrival) {
|
||||
const frame = g.homeworldVictoryReinforcement!;
|
||||
homeworldRule(() => validateCaladanChildArrival(g, caladanArrival, {entrant:entrant.id,
|
||||
territory:to,sector,amount:caladanArrival.spiritual?.amount ?? caladanArrival.terror.amount,elite:0,
|
||||
cause:arrivalCause ?? ''}));
|
||||
g.pendingAmbassador.victoryEvent = frame.event;
|
||||
appendHomeworldArrivalAmbassador(frame,g.pendingAmbassador,undefined,caladanArrival.spiritual,caladanArrival.terror);
|
||||
} else if (arrivalCause === 'caladanReinforcement') {
|
||||
const frame = g.homeworldVictoryReinforcement!;
|
||||
homeworldRule(() => validateCaladanArrivalSource(g, {victoryEvent:frame?.event, entrant:entrant.id,
|
||||
@@ -21875,10 +21893,14 @@ function openTerritoryEntry(
|
||||
cause: NonNullable<Game['pendingTerrorEntry']>['cause'],
|
||||
resume: 'none' | 'wormRide' = 'none',
|
||||
revivalArrival?: RevivalEntryParent,
|
||||
caladanArrival?: CaladanEntryParent,
|
||||
) {
|
||||
if (revivalArrival)
|
||||
homeworldRule(() => validateRevivalArrivalSource(g, revivalArrival,
|
||||
{entrant:entrant.id,territory:to,sector,amount,elite,cause}));
|
||||
if (caladanArrival)
|
||||
homeworldRule(() => validateCaladanChildArrival(g, caladanArrival,
|
||||
{entrant:entrant.id,territory:to,sector,amount,elite,cause}));
|
||||
const victoryEvent = cause === 'caladanReinforcement' ? g.homeworldVictoryReinforcement?.event : undefined;
|
||||
if (cause === 'caladanReinforcement')
|
||||
homeworldRule(() => validateCaladanArrivalSource(g, {victoryEvent, entrant:entrant.id, territory:to, sector, amount, elite, cause}));
|
||||
@@ -21891,6 +21913,7 @@ function openTerritoryEntry(
|
||||
turn: g.turn, phase: g.phase,
|
||||
...(victoryEvent === undefined ? {} : {victoryEvent}),
|
||||
...(revivalArrival ? { revivalArrival } : {}),
|
||||
...(caladanArrival ? {caladanArrival} : {}),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -21921,6 +21944,7 @@ function openTerritoryEntry(
|
||||
resume,
|
||||
undefined,
|
||||
revivalArrival,
|
||||
caladanArrival,
|
||||
);
|
||||
const moritani = byFaction(g, 'moritani');
|
||||
const terrorTokens = moritani &&
|
||||
@@ -21932,7 +21956,7 @@ function openTerritoryEntry(
|
||||
requireRule(!g.response && !g.decision && !g.pendingTerrorEntry && !g.pendingAmbassador,
|
||||
'Finish the other arrival reaction before opening this entry.');
|
||||
if (!terrorTokens.length)
|
||||
return openAmbassadorEntry(g, owner, token, entrant, to, sector, resume, cause, revivalArrival);
|
||||
return openAmbassadorEntry(g, owner, token, entrant, to, sector, resume, cause, revivalArrival, caladanArrival);
|
||||
requireRule(arrivalOverlapModeSupported(g, revivalArrival) && !g.pendingArrivalOverlap &&
|
||||
moritani && g.order.includes(owner.id) && g.order.includes(moritani.id),
|
||||
'Ambassador and Terror overlap still needs a supported classic Ecaz/Moritani table.');
|
||||
@@ -21945,13 +21969,14 @@ function openTerritoryEntry(
|
||||
ambassadorToken: token.id, terrorTokens: terrorTokens.map(t => t.id),
|
||||
...(victoryEvent === undefined ? {} : {victoryEvent}),
|
||||
...(revivalArrival ? {revivalArrival:structuredClone(revivalArrival)} : {}),
|
||||
...(caladanArrival ? {caladanArrival:structuredClone(caladanArrival)} : {}),
|
||||
};
|
||||
overlap.signature = arrivalOverlapSignature(overlap);
|
||||
g.pendingArrivalOverlap = overlap;
|
||||
log(g, `${owner.name} and ${moritani.name} both have optional reactions to ${entrant.name}'s entry in ${territory(to).name}; ${first === 'ambassador' ? owner.name : moritani.name} acts first in storm order.`);
|
||||
if (first === 'terror')
|
||||
return openTerrorEntry(g, entrant, to, sector, amount, elite, cause, 'none', undefined, revivalArrival);
|
||||
openAmbassadorEntry(g, owner, token, entrant, to, sector, 'none', cause, revivalArrival);
|
||||
return openTerrorEntry(g, entrant, to, sector, amount, elite, cause, 'none', undefined, revivalArrival, caladanArrival);
|
||||
openAmbassadorEntry(g, owner, token, entrant, to, sector, 'none', cause, revivalArrival, caladanArrival);
|
||||
overlap.ambassadorEvent = g.pendingAmbassador!.event;
|
||||
return true;
|
||||
}
|
||||
@@ -21990,7 +22015,7 @@ function finishAmbassador(g: Game, deferResume = false) {
|
||||
overlap.stage = 'second';
|
||||
requireRule(openTerrorEntry(g, getPlayer(g, overlap.entrant),
|
||||
overlap.territory, overlap.sector, overlap.amount, overlap.elite,
|
||||
overlap.cause, 'none', undefined, overlap.revivalArrival),
|
||||
overlap.cause, 'none', undefined, overlap.revivalArrival, overlap.caladanArrival),
|
||||
'The originally triggered Terror token is no longer in the stronghold.');
|
||||
return;
|
||||
}
|
||||
@@ -24230,6 +24255,7 @@ function terrorEntryIntegrity(g: Game) {
|
||||
if (!entry) continue;
|
||||
homeworldRule(() => validateTerrorEntrySignature(entry));
|
||||
if (entry.revivalArrival) homeworldRule(() => validateRevivalTerrorEntry(g, entry));
|
||||
if (entry.caladanArrival) homeworldRule(() => validateCaladanChildArrival(g,entry.caladanArrival!,entry));
|
||||
if (entry.victoryEvent !== undefined || entry.cause === 'caladanReinforcement')
|
||||
homeworldRule(() => validateCaladanArrivalSource(g, entry));
|
||||
if (entry.entrySignature === undefined) continue;
|
||||
@@ -24272,10 +24298,14 @@ function openTerrorEntry(
|
||||
resume: 'none' | 'wormRide' | 'ambassador' = 'none',
|
||||
ambassadorEvent?: string,
|
||||
revivalArrival?: RevivalEntryParent,
|
||||
caladanArrival?: CaladanEntryParent,
|
||||
) {
|
||||
if (revivalArrival)
|
||||
homeworldRule(() => validateRevivalArrivalSource(g, revivalArrival,
|
||||
{entrant:entrant.id,territory:to,sector,amount,elite,cause}));
|
||||
if (caladanArrival)
|
||||
homeworldRule(() => validateCaladanChildArrival(g, caladanArrival,
|
||||
{entrant:entrant.id,territory:to,sector,amount,elite,cause}));
|
||||
const moritani = byFaction(g, 'moritani');
|
||||
const overlap = g.pendingArrivalOverlap;
|
||||
const originallyTriggered = !!overlap &&
|
||||
@@ -24312,6 +24342,7 @@ function openTerrorEntry(
|
||||
...(ambassadorEvent ? { ambassadorEvent } : {}),
|
||||
...(cause === 'caladanReinforcement' ? {victoryEvent:g.homeworldVictoryReinforcement?.event} : {}),
|
||||
...(revivalArrival ? {revivalArrival:structuredClone(revivalArrival)} : {}),
|
||||
...(caladanArrival ? {caladanArrival:structuredClone(caladanArrival)} : {}),
|
||||
};
|
||||
if (!TERROR_STRONGHOLDS.includes(to)) {
|
||||
const proofs = tokens.map(token => {
|
||||
@@ -24349,7 +24380,7 @@ function finishTerrorEntry(g: Game) {
|
||||
t.location === overlap.territory);
|
||||
requireRule(token, 'The originally triggered Ambassador token is no longer present.');
|
||||
openAmbassadorEntry(g, getPlayer(g, overlap.ecaz), token,
|
||||
getPlayer(g, overlap.entrant), overlap.territory, overlap.sector, 'none', overlap.cause, overlap.revivalArrival);
|
||||
getPlayer(g, overlap.entrant), overlap.territory, overlap.sector, 'none', overlap.cause, overlap.revivalArrival, overlap.caladanArrival);
|
||||
overlap.ambassadorEvent = g.pendingAmbassador!.event;
|
||||
return;
|
||||
}
|
||||
@@ -24650,6 +24681,8 @@ function decideTerror(g: Game, p: Player, action: Action) {
|
||||
action.sector === undefined || action.sector === entry.sector,
|
||||
'Sneak Attack must enter the recorded sector.',
|
||||
);
|
||||
const caladan = amount > 0 && (entry.victoryEvent || entry.caladanArrival)
|
||||
? homeworldRule(() => createCaladanSneakReceipt(g,entry,p.id,amount,crypto.randomUUID())) : undefined;
|
||||
if (amount > 0) {
|
||||
requireRule(
|
||||
!options.blocked,
|
||||
@@ -24672,7 +24705,14 @@ function decideTerror(g: Game, p: Player, action: Action) {
|
||||
const revivalArrival: RevivalEntryParent | undefined = amount > 0 && entry.revivalArrival
|
||||
? {event:entry.revivalArrival.event,terror:{event:entry.entrySignature!,owner:p.id,
|
||||
territory:entry.territory,sector:entry.sector,amount,elite:0}} : undefined;
|
||||
if (caladan) (g.homeworldVictoryReinforcement!.terrorArrivals ??= []).push(caladan.receipt);
|
||||
finishTerrorEntry(g);
|
||||
if (caladan) {
|
||||
(g.pendingArrivalReaction ??= []).push({entrant:p.id,territory:entry.territory,sector:entry.sector,
|
||||
amount,elite:0,cause:'shipment',resume:'none',turn:g.turn,phase:g.phase,
|
||||
caladanArrival:{event:g.homeworldVictoryReinforcement!.event,terror:caladan.packet},caladanSneakAdvisor:true});
|
||||
return;
|
||||
}
|
||||
// Keep the original two-owner opportunity intact before this new group's
|
||||
// reactions; never overwrite an Ambassador opened by finishing Terror.
|
||||
if (revivalArrival) {
|
||||
@@ -24815,6 +24855,9 @@ function spiritualParentSignature(g:Game,decision:Extract<Decision,{kind:'adviso
|
||||
? g.pendingAmbassador : g.pendingArrivalReaction??null]);
|
||||
}
|
||||
function quoteSpiritualOrder(g:Game,decision:Extract<Decision,{kind:'advisor'}>,action:Action){
|
||||
if(decision.caladanArrival) homeworldRule(() => validateCaladanChildArrival(g,decision.caladanArrival!,
|
||||
{entrant:decision.shipment,territory:decision.caladanArrival!.terror.territory,
|
||||
sector:decision.caladanArrival!.terror.sector,amount:decision.caladanArrival!.terror.amount,elite:0,cause:'shipment'}));
|
||||
const bg=getPlayer(g,decision.player);
|
||||
let order:{player:string;territory:string;sector:number;amount:number;elite:0;advisors:boolean};
|
||||
if(decision.ambassadorEvent){
|
||||
@@ -24846,7 +24889,7 @@ function quoteSpiritualOrder(g:Game,decision:Extract<Decision,{kind:'advisor'}>,
|
||||
}
|
||||
function spiritualAdvisorChoices(g:Game,id:string){
|
||||
const decision=g.decision;
|
||||
if(decision?.kind!=='advisor'||decision.player!==id||(!g.advanced&&!decision.revivalEvent))return null;
|
||||
if(decision?.kind!=='advisor'||decision.player!==id||(!g.advanced&&!decision.revivalEvent&&!decision.caladanArrival))return null;
|
||||
const destination=decision.destination?splitLocation(decision.destination).territory:null;
|
||||
const choices=[...(g.advanced&&destination?gameTerritories(g).find(t=>t.id===destination)?.sectors.map(sector=>
|
||||
({accompany:true,territory:destination,sector,amount:1}))??[]:[]),
|
||||
@@ -24868,7 +24911,7 @@ function spiritualAdvisorChoices(g:Game,id:string){
|
||||
}
|
||||
function spiritualFrameBody(g:Game){
|
||||
const frame=g.pendingSpiritualAdvisor;
|
||||
requireRule(frame&&(g.advanced||frame.phase===4&&!!frame.decision.revivalEvent)&&g.status==='playing'&&frame.turn===g.turn&&frame.phase===g.phase&&
|
||||
requireRule(frame&&(g.advanced||frame.phase===4&&!!frame.decision.revivalEvent||!!frame.decision.caladanArrival)&&g.status==='playing'&&frame.turn===g.turn&&frame.phase===g.phase&&
|
||||
getPlayer(g,frame.decision.player).faction==='beneGesserit'&&
|
||||
frame.signature===JSON.stringify([frame.event,spiritualParentSignature(g,frame.decision)]),
|
||||
'Spiritual Advisors lost the original committed foreign shipment.');
|
||||
@@ -24922,7 +24965,8 @@ function beginSpiritualAdvisor(g:Game,decision:Extract<Decision,{kind:'advisor'}
|
||||
const source:SpiritualAdvisorSource={event:frame.event,owner:decision.player,turn:g.turn,phase:g.phase,
|
||||
territory:order.territory,sector:order.sector,amount:order.amount,
|
||||
...(g.phase===4&&(decision.revivalEvent||g.pendingAmbassador?.revivalEvent)
|
||||
?{revivalEvent:decision.revivalEvent??g.pendingAmbassador!.revivalEvent}: {})};
|
||||
?{revivalEvent:decision.revivalEvent??g.pendingAmbassador!.revivalEvent}: {}),
|
||||
...(decision.caladanArrival ? {victoryEvent:decision.caladanArrival.event} : {})};
|
||||
if(spiritualAdvisorArrival(g,source.owner,source.territory).matching)
|
||||
requireRule(!frame.denied.includes(spiritualAdvisorMatchingKey(source)),
|
||||
'Matching this free packet here was prevented. Choose another legal packet or decline.');
|
||||
@@ -25002,7 +25046,8 @@ function completeSpiritualAdvisorArrival(g:Game){
|
||||
}else{
|
||||
openTerritoryEntry(g,bg,order.territory,order.sector,order.amount,0,'advisor','none',
|
||||
frame.source!.revivalEvent ? {event:frame.source!.revivalEvent,spiritual:frame.source,
|
||||
...(frame.decision.revivalTerror ? {terror:frame.decision.revivalTerror} : {})} : undefined);
|
||||
...(frame.decision.revivalTerror ? {terror:frame.decision.revivalTerror} : {})} : undefined,
|
||||
frame.decision.caladanArrival ? {...frame.decision.caladanArrival,spiritual:frame.source!} : undefined);
|
||||
}
|
||||
}
|
||||
type TransportMatchingIntent=NonNullable<Game['pendingAdvisorTransportMatching']>['intent'];
|
||||
@@ -26495,11 +26540,12 @@ function finishAdvisorReaction(
|
||||
advisorResume?: ResponseWindow['advisorResume'];
|
||||
discoveryEntry?: DiscoveryEntryArrivalChild;
|
||||
advisorAmbassadorEvent?: string;
|
||||
advisorCaladanArrival?: CaladanEntryParent;
|
||||
advisorVictoryEvent?: string;
|
||||
advisorRevivalEvent?: string;
|
||||
advisorRevivalTerror?: RevivalEntryParent['terror'];
|
||||
advisorRemaining?: string[];
|
||||
advisorFollowup?: { shipment: string; destination: string; revivalEvent?: string; revivalTerror?: RevivalEntryParent['terror'] };
|
||||
advisorFollowup?: { shipment: string; destination: string; caladanArrival?: CaladanEntryParent; revivalEvent?: string; revivalTerror?: RevivalEntryParent['terror'] };
|
||||
},
|
||||
) {
|
||||
const bg = byFaction(g, 'beneGesserit')!;
|
||||
@@ -26524,10 +26570,11 @@ function intrusion(
|
||||
wormRide?: boolean;
|
||||
discoveryEntry?: DiscoveryEntryArrivalChild;
|
||||
ambassadorEvent?: string;
|
||||
caladanArrival?: CaladanEntryParent;
|
||||
victoryEvent?: string;
|
||||
revivalEvent?: string;
|
||||
revivalTerror?: RevivalEntryParent['terror'];
|
||||
followup?: { shipment: string; destination: string; revivalEvent?: string; revivalTerror?: RevivalEntryParent['terror'] };
|
||||
followup?: { shipment: string; destination: string; caladanArrival?: CaladanEntryParent; revivalEvent?: string; revivalTerror?: RevivalEntryParent['terror'] };
|
||||
} = {},
|
||||
) {
|
||||
const bg = byFaction(g, 'beneGesserit');
|
||||
@@ -32188,6 +32235,7 @@ function homeworldVictoryWitness(frame: HomeworldVictoryReturn) {
|
||||
function homeworldVictoryReturnIntegrity(g: Game) {
|
||||
homeworldRule(() => validateHomeworldVictoryReturn(g));
|
||||
const frame = g.homeworldVictoryReinforcement;
|
||||
homeworldRule(() => validateCaladanSneakHistory(g));
|
||||
if (frame?.stage === 'arrival' || frame?.stage === 'complete')
|
||||
homeworldRule(() => validateHomeworldArrival(g, frame, 'victoryEvent'));
|
||||
const continuation = g.pendingTreacheryDiscard?.continuation;
|
||||
@@ -32197,6 +32245,31 @@ function homeworldVictoryReturnIntegrity(g: Game) {
|
||||
const decisions = contexts.flatMap((context) => context?.decision?.kind === 'caladanReinforcement' ? [context.decision] : []);
|
||||
const arrivals = g.pendingArrivalReaction?.filter(entry => entry.cause === 'caladanReinforcement' || entry.victoryEvent !== undefined) ?? [];
|
||||
for (const entry of arrivals) homeworldRule(() => validateCaladanArrivalSource(g, entry));
|
||||
for (const entry of g.pendingArrivalReaction??[]) if(entry.caladanArrival)
|
||||
homeworldRule(() => validateCaladanChildArrival(g,entry.caladanArrival!,entry));
|
||||
const childChoices=contexts.flatMap(context => context?.decision &&
|
||||
(context.decision.kind==='advisor'||context.decision.kind==='intrusion') && context.decision.caladanArrival
|
||||
? [context.decision] : []);
|
||||
const childResponses=[...contexts.map(context=>context?.response),
|
||||
g.pendingKarama?.use.kind==='cancel' ? g.pendingKarama.use.response : undefined]
|
||||
.filter((response):response is ResponseWindow=>!!response?.advisorCaladanArrival);
|
||||
for(const choice of childChoices){
|
||||
const parent=choice.caladanArrival!,packet=parent.terror;
|
||||
requireRule(choice.player===byFaction(g,'beneGesserit')?.id &&
|
||||
(choice.kind==='intrusion' ? choice.territory===packet.territory :
|
||||
choice.shipment===packet.owner && choice.destination===location(packet.territory,packet.sector)),
|
||||
'The Caladan Sneak group has lost its original Bene Gesserit opportunity.');
|
||||
homeworldRule(()=>validateCaladanChildArrival(g,parent,{entrant:packet.owner,territory:packet.territory,
|
||||
sector:packet.sector,amount:packet.amount,elite:0,cause:'shipment'}));
|
||||
}
|
||||
for(const response of childResponses){
|
||||
const parent=response.advisorCaladanArrival!,packet=parent.terror;
|
||||
requireRule(response.kind==='advisorFlip' && response.advisors===true &&
|
||||
response.owner===byFaction(g,'beneGesserit')?.id && response.location===packet.territory,
|
||||
'The Caladan Sneak group has lost its original Intrusion response.');
|
||||
homeworldRule(()=>validateCaladanChildArrival(g,parent,{entrant:packet.owner,territory:packet.territory,
|
||||
sector:packet.sector,amount:packet.amount,elite:0,cause:'shipment'}));
|
||||
}
|
||||
const intrusionChoices = contexts.flatMap(context => context?.decision?.kind === 'intrusion' &&
|
||||
context.decision.victoryEvent !== undefined ? [context.decision] : []);
|
||||
const intrusionResponses = [...contexts.map(context => context?.response),
|
||||
@@ -38950,6 +39023,15 @@ function settleAutomaticContinuations(g: Game) {
|
||||
homeworldRule(() => validateCaladanArrivalSource(g, deferred));
|
||||
const entrant = g.players.find(p => p.id === deferred.entrant);
|
||||
requireRule(!!entrant, 'The deferred arrival entry has lost its entrant.');
|
||||
if (deferred.caladanArrival) homeworldRule(() => validateCaladanChildArrival(g,deferred.caladanArrival!,deferred));
|
||||
if (deferred.caladanSneakAdvisor) {
|
||||
requireRule(deferred.caladanArrival && !deferred.caladanArrival.spiritual, 'The queued Sneak group lost its original Caladan source.');
|
||||
const bg=byFaction(g,'beneGesserit'), source={caladanArrival:deferred.caladanArrival};
|
||||
const followup=bg && spiritualAdvisorMaximum(g,bg.id)>0
|
||||
? {shipment:entrant!.id,destination:location(deferred.territory,deferred.sector),...source} : undefined;
|
||||
if(!intrusion(g,entrant!,deferred.territory,{...source,...(followup ? {followup} : {})}) && followup)
|
||||
g.decision={kind:'advisor',player:bg!.id,...followup};
|
||||
}
|
||||
if (deferred.revivalSneakAdvisor) {
|
||||
requireRule(deferred.revivalArrival?.terror && !deferred.revivalArrival.spiritual &&
|
||||
deferred.turn === g.turn && deferred.phase === g.phase,
|
||||
@@ -38964,7 +39046,7 @@ function settleAutomaticContinuations(g: Game) {
|
||||
g.decision = {kind:'advisor',player:bg!.id,...followup};
|
||||
}
|
||||
openTerritoryEntry(g, entrant!, deferred.territory, deferred.sector,
|
||||
deferred.amount, deferred.elite, deferred.cause, deferred.resume, deferred.revivalArrival);
|
||||
deferred.amount, deferred.elite, deferred.cause, deferred.resume, deferred.revivalArrival, deferred.caladanArrival);
|
||||
if (deferred.revivalArrival) resumeHomeworldRevivalReturn(g);
|
||||
continue;
|
||||
}
|
||||
@@ -40917,6 +40999,7 @@ function applyActionInner(
|
||||
? ('wormRide' as const)
|
||||
: undefined,
|
||||
advisorAmbassadorEvent: decision.ambassadorEvent,
|
||||
advisorCaladanArrival: decision.caladanArrival,
|
||||
advisorVictoryEvent: decision.victoryEvent,
|
||||
advisorRevivalEvent: decision.revivalEvent,
|
||||
advisorRevivalTerror: decision.revivalTerror,
|
||||
@@ -40958,7 +41041,7 @@ function applyActionInner(
|
||||
};
|
||||
} else movementTurn(g);
|
||||
} else if (decision.kind === 'advisor') {
|
||||
if(g.advanced||decision.revivalEvent!==undefined){
|
||||
if(g.advanced||decision.revivalEvent!==undefined||decision.caladanArrival!==undefined){
|
||||
requireRule(typeof action.accept==='boolean','Choose the free Spiritual shipment or decline.');
|
||||
if(action.accept){
|
||||
beginSpiritualAdvisor(g,decision,action);
|
||||
|
||||
+31
-16
@@ -3,6 +3,7 @@ import { location, splitLocation, validLocation } from './board';
|
||||
import { HomeworldCustodyError } from './homeworld-custody';
|
||||
import type { SpiritualAdvisorSource } from './spiritual-advisor';
|
||||
import { validRevivalTerrorPacket, validateRevivalTerrorPacket, type RevivalTerrorPacket } from './revival-terror-entry';
|
||||
import { validCaladanEntryParent, validateCaladanTerrorPacket, type CaladanSneakReceipt } from './caladan-arrival';
|
||||
|
||||
export type HomeworldAmbassadorArrival = {
|
||||
event: string;
|
||||
@@ -23,11 +24,12 @@ export type HomeworldArrivalFrame = {
|
||||
destination?: string;
|
||||
ambassadors?: HomeworldAmbassadorArrival[];
|
||||
arrivalSignature?: string;
|
||||
terrorArrivals?: CaladanSneakReceipt[];
|
||||
};
|
||||
export type HomeworldArrivalTag = 'revivalEvent' | 'victoryEvent';
|
||||
export type HomeworldArrivalContext = Pick<
|
||||
Game,
|
||||
'turn' | 'phase' | 'players' | 'pendingAmbassador'
|
||||
'turn' | 'phase' | 'players' | 'pendingAmbassador' | 'homeworldVictoryReinforcement'
|
||||
>;
|
||||
function fail(): never {
|
||||
throw new HomeworldCustodyError(
|
||||
@@ -51,19 +53,23 @@ function keys(value: unknown, allowed: string[]): boolean {
|
||||
function spiritualSource(
|
||||
frame: HomeworldArrivalFrame,
|
||||
entry: HomeworldAmbassadorArrival,
|
||||
): SpiritualAdvisorSource & { revivalEvent: string } {
|
||||
const source = entry.spiritual as (SpiritualAdvisorSource & { revivalEvent?: string }) | undefined;
|
||||
if (!source || !keys(source, ['event', 'owner', 'turn', 'phase', 'territory', 'sector', 'amount', 'revivalEvent'])) fail();
|
||||
): SpiritualAdvisorSource {
|
||||
const source = entry.spiritual;
|
||||
const victory = frame.phase === 6;
|
||||
const tag = victory ? 'victoryEvent' : 'revivalEvent';
|
||||
if (!source || !keys(source, ['event', 'owner', 'turn', 'phase', 'territory', 'sector', 'amount', tag])) fail();
|
||||
const destination = splitLocation(frame.destination!);
|
||||
const origin = entry.terror?.territory ?? destination.territory;
|
||||
if (
|
||||
Object.keys(source).sort().join(',') !== 'amount,event,owner,phase,revivalEvent,sector,territory,turn' ||
|
||||
Object.keys(source).sort().join(',') !== (victory
|
||||
? 'amount,event,owner,phase,sector,territory,turn,victoryEvent'
|
||||
: 'amount,event,owner,phase,revivalEvent,sector,territory,turn') ||
|
||||
!text(source.event) ||
|
||||
!text(source.owner) ||
|
||||
source.owner !== entry.entrant ||
|
||||
source.owner === frame.player ||
|
||||
source.revivalEvent !== frame.event ||
|
||||
frame.phase !== 4 || source.phase !== 4 ||
|
||||
source[tag] !== frame.event ||
|
||||
(victory ? !entry.terror || source.phase !== 6 : frame.phase !== 4 || source.phase !== 4) ||
|
||||
source.turn !== frame.turn ||
|
||||
!Number.isSafeInteger(source.turn) || source.turn < 1 ||
|
||||
!text(source.territory) ||
|
||||
@@ -75,7 +81,7 @@ function spiritualSource(
|
||||
entry.parent !== undefined ||
|
||||
entry.destination !== location(source.territory, source.sector)
|
||||
) fail();
|
||||
return source as SpiritualAdvisorSource & { revivalEvent: string };
|
||||
return source;
|
||||
}
|
||||
|
||||
function terrorPacket(source: RevivalTerrorPacket) {
|
||||
@@ -87,11 +93,12 @@ function terrorPacket(source: RevivalTerrorPacket) {
|
||||
function terrorSource(frame: HomeworldArrivalFrame, entry: HomeworldAmbassadorArrival): RevivalTerrorPacket {
|
||||
const source = entry.terror;
|
||||
const destination = splitLocation(frame.destination!);
|
||||
if (!validRevivalTerrorPacket(source) || entry.parent !== undefined ||
|
||||
if (!source || !(frame.phase === 6 ? validCaladanEntryParent({ event: frame.event, terror: source }) : validRevivalTerrorPacket(source)) ||
|
||||
entry.parent !== undefined ||
|
||||
!validLocation(destination.territory, destination.sector) ||
|
||||
(entry.spiritual === undefined && (entry.entrant !== source.owner ||
|
||||
entry.destination !== location(source.territory, source.sector)))) fail();
|
||||
return source;
|
||||
return source!;
|
||||
}
|
||||
|
||||
/** Arrival provenance is fixed separately from the original revival grant. */
|
||||
@@ -166,7 +173,7 @@ export function homeworldArrivalSignature(
|
||||
entry.spiritual.territory,
|
||||
entry.spiritual.sector,
|
||||
entry.spiritual.amount,
|
||||
spiritualSource(frame, entry).revivalEvent,
|
||||
frame.phase === 6 ? spiritualSource(frame, entry).victoryEvent : spiritualSource(frame, entry).revivalEvent,
|
||||
]]),
|
||||
...(entry.terror === undefined ? [] : [['terror', terrorPacket(entry.terror)]]),
|
||||
]),
|
||||
@@ -269,16 +276,24 @@ export function validateHomeworldArrival(
|
||||
frame.arrivalSignature !== homeworldArrivalSignature(frame) ||
|
||||
frame.ambassadors!.some((entry) => {
|
||||
if (entry.terror !== undefined) {
|
||||
if (tag !== 'revivalEvent') return true;
|
||||
validateRevivalTerrorPacket({ turn: frame.turn, phase: frame.phase, players: g.players,
|
||||
revivalPlayer: frame.player }, entry.terror, frame.destination!, frame.event);
|
||||
if (tag === 'revivalEvent') {
|
||||
validateRevivalTerrorPacket({ turn: frame.turn, phase: frame.phase, players: g.players,
|
||||
revivalPlayer: frame.player }, entry.terror, frame.destination!, frame.event);
|
||||
} else {
|
||||
validateCaladanTerrorPacket({ turn: frame.turn, phase: frame.phase, players: g.players,
|
||||
victoryPlayer: frame.player, terrorArrivals: frame.terrorArrivals ??
|
||||
(g.homeworldVictoryReinforcement?.event === frame.event ? g.homeworldVictoryReinforcement.terrorArrivals : undefined) ?? [] },
|
||||
entry.terror, frame.destination!, frame.event);
|
||||
}
|
||||
}
|
||||
if (entry.spiritual !== undefined) {
|
||||
const entrants = g.players.filter((seat) => seat.id === entry.entrant);
|
||||
const owners = g.players.filter((seat) => seat.id === frame.player);
|
||||
return tag !== 'revivalEvent' || entrants.length !== 1 ||
|
||||
return entrants.length !== 1 ||
|
||||
entrants[0].faction !== 'beneGesserit' || owners.length !== 1 ||
|
||||
(entry.terror === undefined && owners[0].faction !== 'tleilaxu');
|
||||
(tag === 'victoryEvent'
|
||||
? frame.phase !== 6 || entry.terror === undefined || owners[0].faction !== 'atreides'
|
||||
: entry.terror === undefined && owners[0].faction !== 'tleilaxu');
|
||||
}
|
||||
if (entry.terror !== undefined) {
|
||||
return !g.players.some(seat => seat.id === entry.entrant && seat.faction === 'moritani');
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Game } from './engine';
|
||||
import type { HomeworldAmbassadorArrival } from './homeworld-arrival';
|
||||
import type { HomeworldVictoryWitness } from './homeworld-victory-reinforcement';
|
||||
import { HomeworldCustodyError } from './homeworld-custody';
|
||||
import type { CaladanSneakReceipt } from './caladan-arrival';
|
||||
|
||||
export type HomeworldVictoryReturn = HomeworldVictoryWitness & {
|
||||
phase: 6;
|
||||
@@ -11,6 +12,7 @@ export type HomeworldVictoryReturn = HomeworldVictoryWitness & {
|
||||
destination?: string;
|
||||
ambassadors?: HomeworldAmbassadorArrival[];
|
||||
arrivalSignature?: string;
|
||||
terrorArrivals?: CaladanSneakReceipt[];
|
||||
};
|
||||
export type HomeworldVictoryObligation = {
|
||||
event: string;
|
||||
@@ -63,7 +65,7 @@ export function validateHomeworldVictoryReturn(g: Pick<Game, 'turn' | 'phase' |
|
||||
if (frame === undefined || frame === null) return;
|
||||
if (typeof frame !== 'object' || Array.isArray(frame) ||
|
||||
Object.keys(frame).some((key) => !['event', 'turn', 'phase', 'player', 'territory', 'result', 'stage',
|
||||
'signature', 'offer', 'destination', 'ambassadors', 'arrivalSignature'].includes(key)) ||
|
||||
'signature', 'offer', 'destination', 'ambassadors', 'arrivalSignature', 'terrorArrivals'].includes(key)) ||
|
||||
!witnessValid(frame) || frame.signature !== originalSignature(frame) || frame.phase !== 6 ||
|
||||
!['waiting', 'choice', 'arrival', 'complete'].includes(frame.stage) ||
|
||||
!g.homeworlds?.custody ||
|
||||
@@ -83,8 +85,9 @@ export function validateHomeworldVictoryReturn(g: Pick<Game, 'turn' | 'phase' |
|
||||
offer.blocked !== null && (typeof offer.blocked !== 'string' || !offer.blocked) ||
|
||||
offer.signature !== homeworldVictoryOfferSignature(frame, offer))) fail();
|
||||
if (frame.stage === 'waiting' || frame.stage === 'choice') {
|
||||
if (frame.destination !== undefined || frame.ambassadors !== undefined || frame.arrivalSignature !== undefined ||
|
||||
if (frame.destination !== undefined || frame.ambassadors !== undefined || frame.arrivalSignature !== undefined || frame.terrorArrivals !== undefined ||
|
||||
(frame.stage === 'waiting' ? offer !== undefined : !offer)) fail();
|
||||
} else if (typeof frame.destination !== 'string' || !frame.destination || !Array.isArray(frame.ambassadors) ||
|
||||
typeof frame.arrivalSignature !== 'string' || (!offer && frame.destination !== 'decline')) fail();
|
||||
if (frame.terrorArrivals !== undefined && !Array.isArray(frame.terrorArrivals)) fail();
|
||||
}
|
||||
@@ -5411,6 +5411,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Newly revived eligible placement permits Advanced BG Intrusion when entering fighters. Under the provisional SEND composition, Tleilax free forces sent from off-planet to Arrakis also earn ordinary Spiritual accompaniment; Southern on-planet placement does not. The original group stays placed through independent BG counters, and original revival income waits until arrival finishes. No fee, ordinary allowance, Guild stop or Revival-phase industry is added. Phase-four Spiritual sources retain the original return or actual Guild child. Terror-entry continuations remain unfinished.',
|
||||
'After a supported Atreides victory, Caladan with at least six native reserves may add one reserve force to the battle location if an Atreides force survives there. The owned choice follows ordinary battle cleanup and offers an eligible sector or the original foreign Homeworld. This spends no spice or shipment allowance. Leaving the force in reserves is optional; a battle on Caladan itself cannot create an extra counter from reserves already there.',
|
||||
'Caladan reinforcement keeps the original battle identity through refresh and supported Ambassador reactions, so the same victory cannot add a second force. Under the provisional order, reinforcement joins the original winning army before any later Face Dance; its surviving counter counts in the current replacement limit and returns with that army. A return that raises Caladan does not create a second grant. Provisional HMS placement adds to the surviving won interior without an ordinary shipment. Advanced Intrusion resolves before the original Ambassador/Terror entry; Terror classification is provisional. Storm-sector entry stays unavailable, and nested module interactions remain open.',
|
||||
'A positive Sneak Attack after Caladan reinforcement is a separate off-planet group. Provisionally finish both owners of the original entry before its own BG and entry reactions. A declined Ambassador may react again to that new group. Zero creates no source. Basic free Polar and Advanced accompaniment retain their independent prevention/matching/alone decisions; none refunds or repeats the committed Moritani group or Caladan addition.',
|
||||
'At low population, Tupile lets CHOAM learn an opposing faction’s current spice and either its weapon count or defense count, once per faction for the entire game. CHOAM must have forces on that faction’s Homeworld, or that faction must have forces on Tupile. Either Emperor Homeworld supplies the same single entitlement. The answer is recorded privately as a historical observation and does not update when that faction’s hand or spice changes.',
|
||||
'Choose weapons or defenses in the private Tupile panel at a settled action boundary. The server answers immediately without an opponent confirmation, and does not expose individual cards. Count physical held Treachery cards by their default category: Weirding Way is a weapon, Chemistry is a defense, while Worthless cards and leader substitutes count as neither. Selecting the other category later cannot renew a faction’s used entitlement.',
|
||||
'Occupation removes Tupile’s low advantage. New Homeworld games record qualifying foreign presence from setup, including brief sole occupation during ordered battle losses and presence at turn boundaries. Earlier saves without that history, or histories with a Tupile qualifier whose continuing entitlement is unresolved, cannot make a new intelligence request. Already learned observations remain readable. Recorded qualification does not itself award occupied income or settle its expiry.',
|
||||
@@ -5453,6 +5454,9 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'tests/nexus-world-independent-cards.test.ts',
|
||||
'tests/nexus-world-independent-battles.test.ts',
|
||||
'game/caladan-arrival.ts',
|
||||
'tests/caladan-sneak-source.test.ts',
|
||||
'tests/caladan-sneak-engine.test.ts',
|
||||
'tests/fixture-caladan-sneak.ts',
|
||||
'game/homeworld-victory-reinforcement.ts',
|
||||
'components/caladan-reinforcement.tsx',
|
||||
'tests/caladan-arrival-engine.test.ts',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { gameTerritories, splitLocation, type MobileBoard } from './board';
|
||||
import type { FactionId } from './catalog';
|
||||
import type { Game } from './engine';
|
||||
import { validCaladanEntryParent, validateCaladanTerrorPacket, type CaladanEntryParent } from './caladan-arrival';
|
||||
import { currentHomeworldRevivalArrival } from './homeworld-revival-return';
|
||||
import { presenceAt, type ForcePresence } from './force-presence';
|
||||
|
||||
@@ -13,6 +14,7 @@ export type SpiritualAdvisorSource = {
|
||||
sector: number;
|
||||
amount: number;
|
||||
revivalEvent?: string;
|
||||
victoryEvent?: string;
|
||||
};
|
||||
|
||||
export type SpiritualAdvisorPlayer = ForcePresence & {
|
||||
@@ -31,9 +33,10 @@ export type SpiritualAdvisorContext = SpiritualAdvisorBoardContext & MobileBoard
|
||||
turn: number;
|
||||
phase: number;
|
||||
homeworldRevivalReturn?: Pick<NonNullable<Game['homeworldRevivalReturn']>, 'event' | 'turn' | 'phase' | 'stage' | 'destination'>;
|
||||
homeworldVictoryReinforcement?: Pick<NonNullable<Game['homeworldVictoryReinforcement']>, 'event' | 'turn' | 'phase' | 'stage'>;
|
||||
homeworldVictoryReinforcement?: Pick<NonNullable<Game['homeworldVictoryReinforcement']>, 'event' | 'turn' | 'phase' | 'stage'> &
|
||||
Partial<Pick<NonNullable<Game['homeworldVictoryReinforcement']>, 'player' | 'destination' | 'terrorArrivals'>>;
|
||||
pendingSpiritualAdvisor?: Pick<NonNullable<Game['pendingSpiritualAdvisor']>, 'event' | 'turn' | 'phase'> & {
|
||||
decision: Pick<NonNullable<Game['pendingSpiritualAdvisor']>['decision'], 'player' | 'destination' | 'ambassadorEvent' | 'revivalEvent'>;
|
||||
decision: Pick<NonNullable<Game['pendingSpiritualAdvisor']>['decision'], 'player' | 'destination' | 'ambassadorEvent' | 'revivalEvent'> & {shipment?:string;caladanArrival?:CaladanEntryParent};
|
||||
};
|
||||
pendingAmbassador?: Pick<NonNullable<Game['pendingAmbassador']>,
|
||||
'event' | 'revivalEvent' | 'victoryEvent' | 'turn' | 'phase' | 'stage' | 'effect' | 'shipmentReceipt'> | null;
|
||||
@@ -121,7 +124,8 @@ export function spiritualAdvisorMatchingKey(source: SpiritualAdvisorSource): str
|
||||
export function validateSpiritualAdvisorSource(ctx: SpiritualAdvisorContext, source: SpiritualAdvisorSource): void {
|
||||
requireSpiritual(record(source) &&
|
||||
Object.keys(source).sort().join(',') === (source.phase === 4
|
||||
? 'amount,event,owner,phase,revivalEvent,sector,territory,turn' : 'amount,event,owner,phase,sector,territory,turn') &&
|
||||
? 'amount,event,owner,phase,revivalEvent,sector,territory,turn' : source.victoryEvent !== undefined
|
||||
? 'amount,event,owner,phase,sector,territory,turn,victoryEvent' : 'amount,event,owner,phase,sector,territory,turn') &&
|
||||
identifier(source.event) && identifier(source.owner) && positive(source.turn) && source.turn === ctx.turn &&
|
||||
source.phase === ctx.phase && [1, 3, 4, 5, 6, 8].includes(source.phase) && ctx.status === 'playing' &&
|
||||
identifier(source.territory) && Number.isSafeInteger(source.sector) && source.sector >= 0 &&
|
||||
@@ -131,6 +135,8 @@ export function validateSpiritualAdvisorSource(ctx: SpiritualAdvisorContext, sou
|
||||
requireSpiritual(gameTerritories(ctx).some((place) =>
|
||||
place.id === source.territory && place.sectors.includes(source.sector)),
|
||||
'Spiritual Advisors need their current board destination and sector.');
|
||||
if (source.victoryEvent !== undefined)
|
||||
requireSpiritual(source.phase === 6, 'A Caladan child source belongs only to its original Battle phase.');
|
||||
if (source.phase === 6) {
|
||||
const parent = ctx.homeworldVictoryReinforcement;
|
||||
requireSpiritual(record(parent) && identifier(parent.event) && parent.turn === ctx.turn &&
|
||||
@@ -138,6 +144,22 @@ export function validateSpiritualAdvisorSource(ctx: SpiritualAdvisorContext, sou
|
||||
'Victory Spiritual Advisors need their original current-turn Caladan arrival parent.');
|
||||
const frame = ctx.pendingSpiritualAdvisor, ambassador = ctx.pendingAmbassador;
|
||||
const decision = frame?.decision;
|
||||
if (decision?.caladanArrival) {
|
||||
const child=decision.caladanArrival;
|
||||
requireSpiritual(record(frame) && frame.event===source.event && frame.turn===ctx.turn && frame.phase===6 &&
|
||||
decision.player===source.owner && decision.ambassadorEvent===undefined && decision.revivalEvent===undefined &&
|
||||
validCaladanEntryParent(child) && child.spiritual===undefined && child.event===parent.event &&
|
||||
source.victoryEvent===parent.event && identifier(parent.player) && identifier(parent.destination) &&
|
||||
decision.shipment===child.terror.owner && decision.destination===`${child.terror.territory}:${child.terror.sector}`,
|
||||
'Victory Spiritual Advisors need their original committed Sneak Attack group.');
|
||||
try { validateCaladanTerrorPacket({turn:ctx.turn,phase:ctx.phase,players:ctx.players,
|
||||
victoryPlayer:parent.player,terrorArrivals:parent.terrorArrivals??[]},child.terror,parent.destination,parent.event); }
|
||||
catch { throw new SpiritualAdvisorError('Victory Spiritual Advisors lost their committed Sneak Attack history.'); }
|
||||
requireSpiritual(source.territory===child.terror.territory || source.territory==='polar_sink',
|
||||
'Victory Spiritual Advisors need the actual Sneak Attack territory or Polar Sink.');
|
||||
return;
|
||||
}
|
||||
requireSpiritual(source.victoryEvent===undefined, 'This Guild Ambassador source does not carry a Sneak Attack grant.');
|
||||
requireSpiritual(record(frame) && frame.event === source.event && frame.turn === ctx.turn && frame.phase === 6 &&
|
||||
record(decision) && decision.player === source.owner && identifier(decision.ambassadorEvent) &&
|
||||
decision.revivalEvent === undefined && record(ambassador) && ambassador.event === decision.ambassadorEvent &&
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TERRITORIES, validLocation } from './board';
|
||||
import { HomeworldCustodyError } from './homeworld-custody';
|
||||
import { TERROR_STRONGHOLDS } from './moritani-terror';
|
||||
import { revivalEntryParentTuple, validRevivalEntryParent } from './revival-terror-entry';
|
||||
import { caladanEntryParentTuple, validCaladanEntryParent } from './caladan-arrival';
|
||||
|
||||
type TerrorEntry = NonNullable<Game['pendingTerrorEntry']>;
|
||||
|
||||
@@ -24,6 +25,7 @@ export function terrorEntrySignature(entry: TerrorEntry): string {
|
||||
...(entry.nexusPlacements ? [entry.nexusPlacements] : []),
|
||||
...(entry.revivalArrival === undefined ? [] : [revivalEntryParentTuple(entry.revivalArrival)]),
|
||||
...(entry.victoryEvent === undefined ? [] : [['caladanVictory', entry.victoryEvent]]),
|
||||
...(entry.caladanArrival === undefined ? [] : [caladanEntryParentTuple(entry.caladanArrival)]),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -33,13 +35,14 @@ export function terrorSelectionSignature(entry: TerrorEntry): string {
|
||||
entry.turn, entry.phase, entry.resume, entry.ambassadorEvent ?? null,
|
||||
...(entry.nexusPlacements ? [entry.nexusPlacements] : []),
|
||||
...(entry.revivalArrival === undefined ? [] : [revivalEntryParentTuple(entry.revivalArrival)]),
|
||||
...(entry.victoryEvent === undefined ? [] : [['caladanVictory', entry.victoryEvent]])]);
|
||||
...(entry.victoryEvent === undefined ? [] : [['caladanVictory', entry.victoryEvent]]),
|
||||
...(entry.caladanArrival === undefined ? [] : [caladanEntryParentTuple(entry.caladanArrival)])]);
|
||||
}
|
||||
|
||||
/** Older saves without a receipt remain explicit legacy state. Never invent
|
||||
* their original entry count from the destination's present occupants. */
|
||||
export function validateTerrorEntrySignature(entry: TerrorEntry): void {
|
||||
if (entry.entrySignature === undefined && entry.candidates === undefined && entry.selectionSignature === undefined && entry.nexusPlacements === undefined && entry.revivalArrival === undefined && entry.victoryEvent === undefined && entry.cause !== 'caladanReinforcement' && entry.stage !== 'select') return;
|
||||
if (entry.entrySignature === undefined && entry.candidates === undefined && entry.selectionSignature === undefined && entry.nexusPlacements === undefined && entry.revivalArrival === undefined && entry.caladanArrival === undefined && entry.victoryEvent === undefined && entry.cause !== 'caladanReinforcement' && entry.stage !== 'select') return;
|
||||
if (
|
||||
typeof entry.entrySignature !== 'string' ||
|
||||
typeof entry.token !== 'string' ||
|
||||
@@ -74,6 +77,8 @@ export function validateTerrorEntrySignature(entry: TerrorEntry): void {
|
||||
entry.phase < 0 ||
|
||||
entry.phase > 8 ||
|
||||
(entry.revivalArrival !== undefined && !validRevivalEntryParent(entry.revivalArrival)) ||
|
||||
(entry.caladanArrival !== undefined && (!validCaladanEntryParent(entry.caladanArrival) ||
|
||||
entry.victoryEvent !== undefined || entry.revivalArrival !== undefined)) ||
|
||||
(entry.victoryEvent !== undefined && (typeof entry.victoryEvent !== 'string' || !entry.victoryEvent ||
|
||||
entry.cause !== 'caladanReinforcement' || entry.phase !== 6 || entry.revivalArrival !== undefined)) ||
|
||||
(![
|
||||
|
||||
Reference in new issue
Block a user