Connect revived arrivals to native Terror continuations

This commit is contained in:
Quintine committed 2026-10-25 22:04:33 +11:00
1 parent ab69f97cfa
commit 724d9dce1e
20 files changed
+1527 -55

No files matched your search

+32 -11
View File
@@ -1,26 +1,40 @@
import type { Game } from './engine';
import { validateRevivalArrivalSource, revivalOwnsTerrorEntry, validRevivalEntryParent,
revivalEntryParentTuple, type RevivalEntryParent } from './revival-terror-entry';
type Overlap = NonNullable<Game['pendingArrivalOverlap']>;
type TerrorEntry = NonNullable<Game['pendingTerrorEntry']>;
type Overlap = NonNullable<Game['pendingArrivalOverlap']> & { revivalArrival?: RevivalEntryParent };
type TerrorEntry = NonNullable<Game['pendingTerrorEntry']> & { revivalArrival?: RevivalEntryParent };
type AmbassadorEntry = NonNullable<Game['pendingAmbassador']>;
type DiscardContext = Pick<Game, 'pendingArrivalOverlap' | 'turn' | 'phase' | 'order' | 'players'>;
type DiscardContext = Parameters<typeof validateRevivalArrivalSource>[0] & Pick<Game, 'pendingArrivalOverlap' | 'order'>;
function sameRevivalSource(left: RevivalEntryParent | undefined, right: RevivalEntryParent | undefined) {
return validRevivalEntryParent(left) && validRevivalEntryParent(right) &&
JSON.stringify(revivalEntryParentTuple(left)) === JSON.stringify(revivalEntryParentTuple(right));
}
/** The immutable trigger, not later owner choices or child stages. */
export function arrivalOverlapSignature(record: Overlap): string {
return JSON.stringify([record.event, record.turn, record.phase, record.entrant,
record.territory, record.sector, record.amount, record.elite, record.cause,
record.resume, record.ecaz, record.moritani, record.first,
record.ambassadorToken, record.terrorTokens]);
record.ambassadorToken, record.terrorTokens,
...(record.revivalArrival === undefined ? [] : [revivalEntryParentTuple(record.revivalArrival)])]);
}
/** A fresh discard belongs to the currently active owner of one ordinary entry.
/** A fresh discard belongs to the active owner of an ordinary or bound revived entry.
* The parent keeps the other owner's opportunity while the child is suspended. */
function ordinaryDiscardOverlap(g: DiscardContext, owner: Overlap['first']): Overlap | null {
const overlap = g.pendingArrivalOverlap;
const overlap: Overlap | null | undefined = g.pendingArrivalOverlap;
if (!overlap || overlap.signature !== arrivalOverlapSignature(overlap) ||
overlap.turn !== g.turn || overlap.phase !== g.phase || g.phase !== 5 ||
overlap.resume !== 'none' || !['shipment', 'movement'].includes(overlap.cause) ||
overlap.turn !== g.turn || overlap.phase !== g.phase ||
overlap.resume !== 'none' ||
!['first', 'second'].includes(overlap.stage)) return null;
if (overlap.revivalArrival !== undefined) {
try {
validateRevivalArrivalSource(g, overlap.revivalArrival, overlap);
} catch { return null; }
} else if (g.phase !== 5 ||
!['shipment', 'movement'].includes(overlap.cause)) return null;
const ecaz = g.order.indexOf(overlap.ecaz), moritani = g.order.indexOf(overlap.moritani);
if (ecaz < 0 || moritani < 0 || ecaz === moritani ||
overlap.first !== (ecaz < moritani ? 'ambassador' : 'terror') ||
@@ -32,12 +46,17 @@ function ordinaryDiscardOverlap(g: DiscardContext, owner: Overlap['first']): Ove
}
export function overlapOwnsAmbassadorDiscard(g: DiscardContext, entry: AmbassadorEntry): boolean {
const overlap = ordinaryDiscardOverlap(g, 'ambassador');
const overlap = ordinaryDiscardOverlap({ ...g, pendingAmbassador: entry }, 'ambassador');
const arrival = g.homeworldRevivalReturn?.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;
entry.arrivalCause === overlap.cause && entry.turn === overlap.turn && entry.phase === overlap.phase &&
(overlap.revivalArrival === undefined || entry.revivalEvent === overlap.revivalArrival.event &&
!!arrival && sameRevivalSource(overlap.revivalArrival, { event: overlap.revivalArrival.event,
...(arrival.spiritual === undefined ? {} : { spiritual: arrival.spiritual }),
...(arrival.terror === undefined ? {} : { terror: arrival.terror }) }));
}
export function overlapOwnsTerrorDiscard(g: DiscardContext, entry: TerrorEntry, owner: string): boolean {
@@ -46,7 +65,9 @@ export function overlapOwnsTerrorDiscard(g: DiscardContext, entry: TerrorEntry,
overlap.terrorTokens.includes(entry.token) && entry.entrant === overlap.entrant &&
entry.territory === overlap.territory && entry.sector === overlap.sector &&
entry.amount === overlap.amount && entry.elite === overlap.elite &&
entry.cause === overlap.cause && entry.turn === overlap.turn && entry.phase === overlap.phase;
entry.cause === overlap.cause && entry.turn === overlap.turn && entry.phase === overlap.phase &&
(overlap.revivalArrival === undefined ||
sameRevivalSource(overlap.revivalArrival, entry.revivalArrival) && revivalOwnsTerrorEntry(g, entry));
}
/** The overlap owns the worm return while either serialized Terror prompt is live. */
+74 -19
View File
@@ -149,6 +149,7 @@ import {
} from './homeworld-revival-return';
import { homeworldRevivalDestinations, quoteHomeworldRevivalDestination } from './homeworld-revival-destinations';
import { terrorEntrySignature, terrorSelectionSignature, validateTerrorEntrySignature } from './terror-entry-receipt';
import { validateRevivalArrivalSource, validateRevivalTerrorEntry, type RevivalEntryParent } from './revival-terror-entry';
import { arrivalOverlapSignature, overlapOwnsAmbassadorDiscard, overlapOwnsTerrorDiscard, overlapOwnsWormTerror } from './arrival-overlap';
import { ambassadorPlacementDestination, ambassadorPlacementDestinations, quoteEcazAmbassadorPlacement } from './ecaz-ambassador-placement';
import { GuildAmbassadorMarkerError, markerDestinations, markerOwner, quoteGuildAmbassadorMarker,
@@ -1026,6 +1027,7 @@ export type Decision =
destination?: string;
ambassadorEvent?: string;
revivalEvent?: string;
revivalTerror?: RevivalEntryParent['terror'];
}
| {
kind: 'intrusion';
@@ -1035,7 +1037,8 @@ export type Decision =
discoveryEntry?: DiscoveryEntryArrivalChild;
ambassadorEvent?: string;
revivalEvent?: string;
followup?: { shipment: string; destination: string; revivalEvent?: string };
revivalTerror?: RevivalEntryParent['terror'];
followup?: { shipment: string; destination: string; revivalEvent?: string; revivalTerror?: RevivalEntryParent['terror'] };
}
| { kind: 'advisorBattle'; player: string; territories: string[] }
| {
@@ -1112,8 +1115,9 @@ export type ResponseWindow = {
discoveryEntry?: DiscoveryEntryArrivalChild;
advisorAmbassadorEvent?: string;
advisorRevivalEvent?: string;
advisorRevivalTerror?: RevivalEntryParent['terror'];
advisorRemaining?: string[];
advisorFollowup?: { shipment: string; destination: string; revivalEvent?: string };
advisorFollowup?: { shipment: string; destination: string; revivalEvent?: string; revivalTerror?: RevivalEntryParent['terror'] };
amount?: number;
elite?: number;
recipient?: string;
@@ -1210,7 +1214,6 @@ type PendingHomeworldShipment = HomeworldShipmentIntent & {
allyPayment: number;
pools: ReturnType<typeof quoteHomeworldShipment>['sources'] | ReturnType<typeof quoteGuildHomeworldShipment>['boardSources'];
};
type RevivalEntryParent = { event: string; spiritual?: SpiritualAdvisorSource };
type PendingShipment = {
/** Only new declarations use the bonus; legacy quoted shipments retain their original price. */
smuggler?: SmugglerShipment;
@@ -1925,6 +1928,7 @@ export type Game = {
dukeVidal?: DukeState;
dukeAcquisitionTurn?: number;
pendingTerrorEntry?: {
revivalArrival?: RevivalEntryParent;
/** Original public arrival, absent only in legacy saves. */
entrySignature?: string;
candidates?: string[];
@@ -1981,9 +1985,12 @@ export type Game = {
turn: number;
phase: number;
revivalArrival?: RevivalEntryParent;
/** A committed revival Sneak group waits for the original overlap before BG reactions. */
revivalSneakAdvisor?: true;
}[] | null;
/** One committed entry with two independently optional expansion reactions. */
pendingArrivalOverlap?: {
revivalArrival?: RevivalEntryParent;
event: string;
signature: string;
turn: number;
@@ -7268,6 +7275,7 @@ function treacheryDiscardIntegrity(g: Game) {
(t) => t.id === entry?.token,
);
if (entry?.cause === 'ambassador') ambassadorRelocationIntegrity(g);
if (entry?.revivalArrival) homeworldRule(() => validateRevivalTerrorEntry(g, entry));
const sabotage = source === 'sabotage';
const discarder = sabotage ? entrant : owner;
requireRule(
@@ -7288,6 +7296,7 @@ function treacheryDiscardIntegrity(g: Game) {
'guildTransport',
'advisor',
'wormRide',
'homeworldRevival',
'ambassador',
].includes(entry.cause) &&
['none', 'wormRide', 'ambassador'].includes(entry.resume) &&
@@ -7297,7 +7306,7 @@ function treacheryDiscardIntegrity(g: Game) {
? (entry.resume === 'wormRide' || overlapOwnsWormTerror(g, entry)) &&
g.phase === 1 &&
entrant.faction === 'fremen'
: entry.resume === 'none') &&
: entry.resume === 'none' && (entry.cause !== 'homeworldRevival' || !!entry.revivalArrival)) &&
Number.isSafeInteger(entry.amount) &&
entry.amount >= 0 &&
Number.isSafeInteger(entry.elite) &&
@@ -7323,6 +7332,7 @@ function treacheryDiscardIntegrity(g: Game) {
const { entry, aftermath, handBefore } = continuation;
atomicsIntegrity(g);
if (entry?.cause === 'ambassador') ambassadorRelocationIntegrity(g);
if (entry?.revivalArrival) homeworldRule(() => validateRevivalTerrorEntry(g, entry));
const owner = byFaction(g, 'moritani');
const token = g.moritaniTerror?.tokens.find(row => row.id === entry?.token);
requireRule(!g.pendingTerrorEntry && !g.pendingExchange && !g.summonedWorm &&
@@ -7332,8 +7342,8 @@ function treacheryDiscardIntegrity(g: Game) {
: entry.cause === 'wormRide' ?
(entry.resume === 'wormRide' || overlapOwnsWormTerror(g,entry)) && g.phase === 1 &&
g.players.some(player => player.id === entry.entrant && player.faction === 'fremen')
: entry.resume === 'none') &&
['shipment', 'movement', 'guildTransport', 'advisor', 'wormRide', 'ambassador'].includes(entry.cause) &&
: entry.resume === 'none' && (entry.cause !== 'homeworldRevival' || !!entry.revivalArrival)) &&
['shipment', 'movement', 'guildTransport', 'advisor', 'wormRide', 'homeworldRevival', 'ambassador'].includes(entry.cause) &&
entry.entrant !== owner?.id && entry.territory === aftermath.territory &&
terrorEntryLocationAllowed(g,entry) && validGameLocation(g, entry.territory, entry.sector) &&
token?.kind === 'atomics' && token.status === 'removed' &&
@@ -17479,7 +17489,8 @@ function ambassadorEffectBlock(
return 'The entrant has no held Traitor Card to inspect.';
return null;
}
function arrivalOverlapModeSupported(g: Game): boolean {
function arrivalOverlapModeSupported(g: Game, revivalArrival?: RevivalEntryParent): boolean {
if (revivalArrival && g.homeworlds && byFaction(g, 'ecaz') && byFaction(g, 'moritani')) return true;
if (bareBasicSemutaProfile(g) && byFaction(g, 'ecaz') && byFaction(g, 'moritani')) return true;
if ((independentNexusProfile(g) || independentDiscoveryProfile(g)) && byFaction(g, 'ecaz') && byFaction(g, 'moritani')) return true;
if ((mixedE3NexusLeaderSkillsProfile(g) || mixedE3NexusProfile(g)) &&
@@ -17504,9 +17515,11 @@ 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.revivalArrival)
homeworldRule(() => validateRevivalArrivalSource(g, record.revivalArrival!, record));
const active = record.stage === 'first' ? record.first :
record.first === 'ambassador' ? 'terror' : 'ambassador';
requireRule(g.status === 'playing' && arrivalOverlapModeSupported(g) &&
requireRule(g.status === 'playing' && arrivalOverlapModeSupported(g, record.revivalArrival) &&
record.turn === g.turn && record.phase === g.phase &&
typeof record.event === 'string' && record.event.length > 0 &&
typeof record.signature === 'string' &&
@@ -17544,6 +17557,7 @@ function arrivalOverlapIntegrity(g: Game) {
terror.territory === record.territory &&
terror.turn === record.turn && terror.phase === record.phase &&
terror.cause === record.cause &&
JSON.stringify(terror.revivalArrival) === JSON.stringify(record.revivalArrival) &&
record.terrorTokens.includes(terror.token)),
'The saved Ambassador/Terror overlap lost its original ordered arrival.');
}
@@ -17578,7 +17592,7 @@ function openAmbassadorEntry(
'This entry has lost its original revived group.');
g.pendingAmbassador.revivalEvent = frame.event;
homeworldRule(() => appendHomeworldArrivalAmbassador(frame, g.pendingAmbassador!, undefined,
revivalArrival.spiritual));
revivalArrival.spiritual, revivalArrival.terror));
}
log(g, `${owner.name} may trigger the ${faction(token.effect).name} Ambassador because ${entrant.name} entered ${territory(to).name}. The entrant’s remaining actions wait for this opportunity.`);
return true;
@@ -17602,6 +17616,9 @@ function openTerritoryEntry(
resume: 'none' | 'wormRide' = 'none',
revivalArrival?: RevivalEntryParent,
) {
if (revivalArrival)
homeworldRule(() => validateRevivalArrivalSource(g, revivalArrival,
{entrant:entrant.id,territory:to,sector,amount,elite,cause}));
// The arrival's own interaction (guild payment, advisor choice, storm
// protection) settles first; the entry then opens from the automatic pass.
if (g.response || g.decision || g.pendingTerrorEntry || g.pendingAmbassador ||
@@ -17638,6 +17655,8 @@ function openTerritoryEntry(
elite,
cause,
resume,
undefined,
revivalArrival,
);
const moritani = byFaction(g, 'moritani');
const terrorTokens = moritani &&
@@ -17650,8 +17669,7 @@ function openTerritoryEntry(
'Finish the other arrival reaction before opening this entry.');
if (!terrorTokens.length)
return openAmbassadorEntry(g, owner, token, entrant, to, sector, resume, cause, revivalArrival);
requireRule(!revivalArrival, 'Revival deployment with overlapping Terror and Ambassador reactions is still being implemented.');
requireRule(arrivalOverlapModeSupported(g) && !g.pendingArrivalOverlap &&
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.');
const first = g.order.indexOf(owner.id) < g.order.indexOf(moritani.id)
@@ -17661,13 +17679,14 @@ function openTerritoryEntry(
entrant: entrant.id, territory: to, sector, amount, elite, cause, resume,
ecaz: owner.id, moritani: moritani.id, first, stage: 'first',
ambassadorToken: token.id, terrorTokens: terrorTokens.map(t => t.id),
...(revivalArrival ? {revivalArrival:structuredClone(revivalArrival)} : {}),
};
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');
openAmbassadorEntry(g, owner, token, entrant, to, sector, 'none', cause);
return openTerrorEntry(g, entrant, to, sector, amount, elite, cause, 'none', undefined, revivalArrival);
openAmbassadorEntry(g, owner, token, entrant, to, sector, 'none', cause, revivalArrival);
overlap.ambassadorEvent = g.pendingAmbassador!.event;
return true;
}
@@ -17706,7 +17725,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'),
overlap.cause, 'none', undefined, overlap.revivalArrival),
'The originally triggered Terror token is no longer in the stronghold.');
return;
}
@@ -19944,6 +19963,7 @@ function terrorEntryIntegrity(g: Game) {
for (const entry of entries) {
if (!entry) continue;
homeworldRule(() => validateTerrorEntrySignature(entry));
if (entry.revivalArrival) homeworldRule(() => validateRevivalTerrorEntry(g, entry));
if (entry.entrySignature === undefined) continue;
requireRule(entry.turn === g.turn && entry.phase === g.phase &&
g.players.some((p) => p.id === entry.entrant && p.faction !== 'moritani') &&
@@ -19983,7 +20003,11 @@ function openTerrorEntry(
cause: NonNullable<Game['pendingTerrorEntry']>['cause'],
resume: 'none' | 'wormRide' | 'ambassador' = 'none',
ambassadorEvent?: string,
revivalArrival?: RevivalEntryParent,
) {
if (revivalArrival)
homeworldRule(() => validateRevivalArrivalSource(g, revivalArrival,
{entrant:entrant.id,territory:to,sector,amount,elite,cause}));
const moritani = byFaction(g, 'moritani');
const overlap = g.pendingArrivalOverlap;
const originallyTriggered = !!overlap &&
@@ -20018,6 +20042,7 @@ function openTerrorEntry(
stage: tokens.length > 1 ? 'select' : 'offer',
resume,
...(ambassadorEvent ? { ambassadorEvent } : {}),
...(revivalArrival ? {revivalArrival:structuredClone(revivalArrival)} : {}),
};
if (!TERROR_STRONGHOLDS.includes(to)) {
const proofs = tokens.map(token => {
@@ -20055,7 +20080,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);
getPlayer(g, overlap.entrant), overlap.territory, overlap.sector, 'none', overlap.cause, overlap.revivalArrival);
overlap.ambassadorEvent = g.pendingAmbassador!.event;
return;
}
@@ -20375,7 +20400,18 @@ function decideTerror(g: Game, p: Player, action: Action) {
`${p.name} sent ${amount} reserves into ${territory(entry.territory).name} through Sneak Attack.`,
);
} else log(g, `${p.name} sent no forces through Sneak Attack.`);
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;
finishTerrorEntry(g);
// Keep the original two-owner opportunity intact before this new group's
// reactions; never overwrite an Ambassador opened by finishing Terror.
if (revivalArrival) {
(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,revivalArrival,revivalSneakAdvisor:true});
return;
}
// The Terror effect is the arrival itself, so the adopted order resolves
// the committed entry first and then offers the Bene Gesserit reactions
// the same off-planet reserve entry already uses for ordinary shipments.
@@ -20696,7 +20732,8 @@ function completeSpiritualAdvisorArrival(g:Game){
{...order,amount:order.amount as 1|2,advisors:isAdvisor(bg,order.territory)});
}else{
openTerritoryEntry(g,bg,order.territory,order.sector,order.amount,0,'advisor','none',
frame.source!.revivalEvent ? {event:frame.source!.revivalEvent,spiritual:frame.source} : undefined);
frame.source!.revivalEvent ? {event:frame.source!.revivalEvent,spiritual:frame.source,
...(frame.decision.revivalTerror ? {terror:frame.decision.revivalTerror} : {})} : undefined);
}
}
type TransportMatchingIntent=NonNullable<Game['pendingAdvisorTransportMatching']>['intent'];
@@ -22120,8 +22157,9 @@ function finishAdvisorReaction(
discoveryEntry?: DiscoveryEntryArrivalChild;
advisorAmbassadorEvent?: string;
advisorRevivalEvent?: string;
advisorRevivalTerror?: RevivalEntryParent['terror'];
advisorRemaining?: string[];
advisorFollowup?: { shipment: string; destination: string; revivalEvent?: string };
advisorFollowup?: { shipment: string; destination: string; revivalEvent?: string; revivalTerror?: RevivalEntryParent['terror'] };
},
) {
const bg = byFaction(g, 'beneGesserit')!;
@@ -22147,7 +22185,8 @@ function intrusion(
discoveryEntry?: DiscoveryEntryArrivalChild;
ambassadorEvent?: string;
revivalEvent?: string;
followup?: { shipment: string; destination: string; revivalEvent?: string };
revivalTerror?: RevivalEntryParent['terror'];
followup?: { shipment: string; destination: string; revivalEvent?: string; revivalTerror?: RevivalEntryParent['terror'] };
} = {},
) {
const bg = byFaction(g, 'beneGesserit');
@@ -27956,8 +27995,9 @@ function stampHomeworldRevivalProgress(g: Game) {
}
function homeworldRevivalReturnIntegrity(g: Game) {
const frame = g.homeworldRevivalReturn;
homeworldRule(() => validateHomeworldRevivalReturn(g, frame));
const continuation = g.pendingTreacheryDiscard?.continuation;
homeworldRule(() => validateHomeworldRevivalReturn(
continuation?.kind === 'ambassador' ? {...g,pendingAmbassador:continuation.entry} : g, frame));
const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume,
g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume,
continuation && 'resume' in continuation ? continuation.resume : null];
@@ -33403,6 +33443,19 @@ function settleAutomaticContinuations(g: Game) {
if (!g.pendingArrivalReaction.length) g.pendingArrivalReaction = null;
const entrant = g.players.find(p => p.id === deferred.entrant);
requireRule(!!entrant, 'The deferred arrival entry has lost its entrant.');
if (deferred.revivalSneakAdvisor) {
requireRule(deferred.revivalArrival?.terror && !deferred.revivalArrival.spiritual &&
deferred.turn === g.turn && deferred.phase === g.phase,
'The queued Sneak reactions have lost their committed revival entry.');
homeworldRule(() => validateRevivalArrivalSource(g, deferred.revivalArrival!, deferred));
const bg = byFaction(g, 'beneGesserit');
const source = {revivalEvent:deferred.revivalArrival!.event,
revivalTerror:deferred.revivalArrival!.terror};
const followup = bg && bg.id !== entrant!.id && 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};
}
openTerritoryEntry(g, entrant!, deferred.territory, deferred.sector,
deferred.amount, deferred.elite, deferred.cause, deferred.resume, deferred.revivalArrival);
if (deferred.revivalArrival) resumeHomeworldRevivalReturn(g);
@@ -35287,6 +35340,7 @@ function applyActionInner(
: undefined,
advisorAmbassadorEvent: decision.ambassadorEvent,
advisorRevivalEvent: decision.revivalEvent,
advisorRevivalTerror: decision.revivalTerror,
discoveryEntry: decision.discoveryEntry,
};
if (action.accept)
@@ -37829,6 +37883,7 @@ export function viewGame(state: Game, id: string) {
sector: entry.sector,
cause: entry.cause,
stage: entry.stage,
...(entry.revivalArrival ? {revivalArrival:true} : {}),
...(me.faction === 'moritani' && entry.stage === 'select'
? { candidates: entry.candidates!.map((id) => {
const candidate = g.moritaniTerror!.tokens.find((t) => t.id === id)!;
+48 -2
View File
@@ -2,6 +2,7 @@ import type { Game } from './engine';
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';
export type HomeworldAmbassadorArrival = {
event: string;
@@ -9,6 +10,7 @@ export type HomeworldAmbassadorArrival = {
destination: string;
parent?: string;
spiritual?: SpiritualAdvisorSource;
terror?: RevivalTerrorPacket;
completed: boolean;
};
export type HomeworldArrivalFrame = {
@@ -53,6 +55,7 @@ function spiritualSource(
const source = entry.spiritual as (SpiritualAdvisorSource & { revivalEvent?: string }) | undefined;
if (!source || !keys(source, ['event', 'owner', 'turn', 'phase', 'territory', 'sector', 'amount', 'revivalEvent'])) 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' ||
!text(source.event) ||
@@ -67,7 +70,7 @@ function spiritualSource(
!Number.isSafeInteger(source.sector) ||
!validLocation(source.territory, source.sector) ||
!validLocation(destination.territory, destination.sector) ||
(source.territory !== destination.territory && source.territory !== 'polar_sink') ||
(source.territory !== origin && source.territory !== 'polar_sink') ||
(source.amount !== 1 && source.amount !== 2) ||
entry.parent !== undefined ||
entry.destination !== location(source.territory, source.sector)
@@ -75,6 +78,22 @@ function spiritualSource(
return source as SpiritualAdvisorSource & { revivalEvent: string };
}
function terrorPacket(source: RevivalTerrorPacket) {
return [source.event, source.owner, source.territory, source.sector, source.amount, source.elite];
}
/** Sneak Attack is a distinct committed packet, not another disposition of the
* original revived counters. Its captured Terror witness stays engine-owned. */
function terrorSource(frame: HomeworldArrivalFrame, entry: HomeworldAmbassadorArrival): RevivalTerrorPacket {
const source = entry.terror;
const destination = splitLocation(frame.destination!);
if (!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;
}
/** Arrival provenance is fixed separately from the original revival grant. */
export function homeworldArrivalSignature(
frame: HomeworldArrivalFrame,
@@ -82,6 +101,8 @@ export function homeworldArrivalSignature(
if (!text(frame.destination) || !Array.isArray(frame.ambassadors)) fail();
const seen = new Set<string>();
const spiritualEvents = new Set<string>();
const terrorEvents = new Map<string, string>();
const terrorRoots = new Set<string>();
let primary = false;
for (const [index, entry] of frame.ambassadors.entries()) {
if (
@@ -91,6 +112,7 @@ export function homeworldArrivalSignature(
'destination',
'parent',
'spiritual',
'terror',
'completed',
]) ||
!text(entry.event) ||
@@ -101,10 +123,21 @@ export function homeworldArrivalSignature(
seen.has(entry.event)
)
fail();
if (entry.terror !== undefined) {
const source = terrorSource(frame, entry), packet = JSON.stringify(terrorPacket(source));
if (terrorEvents.has(source.event) && terrorEvents.get(source.event) !== packet) fail();
terrorEvents.set(source.event, packet);
if (entry.spiritual === undefined) {
if (terrorRoots.has(source.event)) fail();
terrorRoots.add(source.event);
}
}
if (entry.spiritual !== undefined) {
const source = spiritualSource(frame, entry);
if (spiritualEvents.has(source.event)) fail();
spiritualEvents.add(source.event);
} else if (entry.terror !== undefined) {
// Its separate public packet was checked above; it is not the primary root.
} else if (entry.parent === undefined) {
if (primary || entry.entrant !== frame.player || entry.destination !== frame.destination) fail();
primary = true;
@@ -135,6 +168,7 @@ export function homeworldArrivalSignature(
entry.spiritual.amount,
spiritualSource(frame, entry).revivalEvent,
]]),
...(entry.terror === undefined ? [] : [['terror', terrorPacket(entry.terror)]]),
]),
]);
}
@@ -152,6 +186,7 @@ export function appendHomeworldArrivalAmbassador(
},
parent?: string,
spiritual?: SpiritualAdvisorSource,
terror?: RevivalTerrorPacket,
): void {
if (
frame.stage !== 'arrival' ||
@@ -169,6 +204,7 @@ export function appendHomeworldArrivalAmbassador(
completed: false,
...(parent === undefined ? {} : { parent }),
...(spiritual === undefined ? {} : { spiritual }),
...(terror === undefined ? {} : { terror }),
};
const ambassadors = [
...frame.ambassadors!,
@@ -179,6 +215,7 @@ export function appendHomeworldArrivalAmbassador(
ambassadors,
});
if (spiritual !== undefined) appended.spiritual = structuredClone(spiritual);
if (terror !== undefined) appended.terror = structuredClone(terror);
frame.ambassadors = ambassadors;
frame.arrivalSignature = arrivalSignature;
}
@@ -231,11 +268,20 @@ export function validateHomeworldArrival(
(frame.turn !== g.turn || frame.phase !== g.phase)) ||
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 (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 ||
entrants[0].faction !== 'beneGesserit' || owners.length !== 1 || owners[0].faction !== 'tleilaxu';
entrants[0].faction !== 'beneGesserit' || owners.length !== 1 ||
(entry.terror === undefined && owners[0].faction !== 'tleilaxu');
}
if (entry.terror !== undefined) {
return !g.players.some(seat => seat.id === entry.entrant && seat.faction === 'moritani');
}
return !g.players.some((seat) => seat.id === entry.entrant &&
(entry.parent === undefined || seat.faction === 'beneGesserit'));
-7
View File
@@ -6,7 +6,6 @@ import { homeworldContext } from './homeworld-game';
import { HOMEWORLD_CARDS } from './homeworld-cards';
import { homeworldForceGroups, quoteHomeworldCustody, HomeworldCustodyError } from './homeworld-custody';
import { quoteNativeReserveWithdrawal } from './homeworld-native-reserves';
import { lowGrummanRevealBlock } from './homeworld-collection';
import type { HomeworldRevivalDeploymentQuote } from './homeworld-revival-deployment';
type Context = Pick<Game, 'advanced' | 'players' | 'homeworlds' | 'storm' | 'mobileStronghold' | 'moritaniTerror'>;
@@ -30,8 +29,6 @@ export function homeworldRevivalDestinations(
const p = g.players.find((seat) => seat.id === player);
if (!p || !g.homeworlds?.custody)
throw new HomeworldCustodyError('Revival placement needs its seated native Homeworld owner.');
const amount = grant.normal + grant.elite;
const moritani = g.players.find(seat => seat.faction === 'moritani');
const destinations: HomeworldRevivalDestination[] = gameTerritories(g).flatMap((t) => {
if (grant.kind === 'fedaykin' && presenceAt(p, t.id) === 0) return [];
return t.sectors.map((sector) => {
@@ -45,10 +42,6 @@ export function homeworldRevivalDestinations(
if (!blocked && t.id === MOBILE_STRONGHOLD)
blocked = 'Revival placement in the Hidden Mobile Stronghold awaits its shipment-classification ruling.';
blocked ??= territoryEntryBlock(g.players, p.id, t.id);
if (!blocked && moritani && p.id !== moritani.id && p.id !== moritani.ally &&
!lowGrummanRevealBlock(g, moritani.id, amount) &&
g.moritaniTerror?.tokens.some(token => token.status === 'placed' && token.location === t.id))
blocked = 'Revival SEND into Terror awaits its original entry continuation.';
return { id: location(t.id, sector), name: `${t.name}${sector ? ` · sector ${sector}` : ''}`,
territory: t.id, sector, blocked };
});
+16 -5
View File
@@ -2,6 +2,7 @@ import { validAmbassadorResume } from './ambassador-resume';
import { validateDiscoveryEntryArrivalChild, validateDiscoveryEntryRound } from './discovery-entry';
import { ambassadorPhaseAllowed } from './ambassador-phase';
import { validateHomeworldRevivalReturn } from './homeworld-revival-return';
import { validateRevivalArrivalSource } from './revival-terror-entry';
import { HomeworldCustodyError } from './homeworld-custody';
import {
validateGuildAmbassadorArrivalContext,
@@ -516,7 +517,13 @@ export function quoteMovementCancellation(
if (response.advisorResume === 'wormRide') return null;
if (response.advisorRevivalEvent !== undefined) {
const parent = g.homeworldRevivalReturn;
try { validateHomeworldRevivalReturn(g, parent); }
const terror = response.advisorRevivalTerror;
try {
validateHomeworldRevivalReturn(g, parent);
if (terror) validateRevivalArrivalSource(g, {event:response.advisorRevivalEvent,terror},
{entrant:terror.owner,territory:terror.territory,sector:terror.sector,
amount:terror.amount,elite:terror.elite,cause:'shipment'});
}
catch (error) {
if (error instanceof HomeworldCustodyError) throw new MovementCancellationError(error.message);
throw error;
@@ -525,17 +532,21 @@ export function quoteMovementCancellation(
parent?.stage === 'arrival' && parent.event === response.advisorRevivalEvent &&
parent.turn === g.turn && parent.phase === g.phase && parent.player !== owner.id &&
typeof parent.destination === 'string' && sectorKey(g, parent.destination) &&
response.location === splitLocation(parent.destination).territory &&
response.location === (terror?.territory ?? splitLocation(parent.destination).territory) &&
response.advisorResume === undefined && response.advisorRemaining === undefined,
'The canceled Intrusion lost its original committed revived-force arrival.');
const followup = response.advisorFollowup;
requireContext(followup === undefined || (parent.quote.kind === 'tleilax' &&
followup.shipment === parent.player && followup.destination === parent.destination &&
followup.revivalEvent === parent.event),
requireContext(followup === undefined || ((terror || parent.quote.kind === 'tleilax') &&
followup.shipment === (terror?.owner ?? parent.player) &&
followup.destination === (terror ? location(terror.territory,terror.sector) : parent.destination) &&
followup.revivalEvent === parent.event &&
JSON.stringify(followup.revivalTerror) === JSON.stringify(terror)),
'The revived Intrusion lost its original Spiritual Advisor followup.');
return { kind: 'advisorFlip', successor: followup &&
homeworldSpiritualAdvisorLimit(g, owner.id, 'polar_sink') > 0 ? 'advisor' : 'none' };
}
requireContext(response.advisorRevivalTerror === undefined,
'A Sneak Intrusion must retain its original revival event.');
requireContext(
g.phase === 5,
'This advisor cancellation is outside its shipment and movement phase.',
+6 -2
View File
@@ -12,6 +12,7 @@ import {
import { TERROR_KINDS } from './moritani-terror';
import { validateTerrorEntrySignature } from './terror-entry-receipt';
import { overlapOwnsWormTerror } from './arrival-overlap';
import { validateRevivalTerrorEntry, type RevivalArrivalContext } from './revival-terror-entry';
export class MoritaniAllianceCancellationError extends Error {
constructor(message: string) {
@@ -30,7 +31,7 @@ export type MoritaniAllianceCancellationContext = Pick<
| 'moritaniTerror'
| 'pendingAmbassador'
| 'ecazAmbassadors'
> & TerrorLocationContext;
> & TerrorLocationContext & RevivalArrivalContext;
export type MoritaniAllianceCancellationQuote = {
kind: 'moritaniAlliance';
owner: string;
@@ -86,6 +87,7 @@ export function quoteMoritaniAllianceCancellation(
const entrants = g.players.filter((p) => p.id === entry.entrant);
try {
validateTerrorEntrySignature(entry);
if (entry.revivalArrival !== undefined) validateRevivalTerrorEntry(g, entry);
} catch (error) {
throw new MoritaniAllianceCancellationError((error as Error).message);
}
@@ -164,7 +166,9 @@ export function quoteMoritaniAllianceCancellation(
'Only an Ambassador entry may carry its event.',
);
requireSource(
entry.cause === 'wormRide'
entry.revivalArrival !== undefined
? entry.resume === 'none'
: entry.cause === 'wormRide'
? g.phase === 1 &&
(entry.resume === 'wormRide' || overlapOwnsWormTerror(g, entry)) &&
entrants[0].faction === 'fremen'
+2 -2
View File
@@ -4576,7 +4576,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Quote a group of normal revivals using population before that group returns. A later group uses the resulting population: a low Fremen group can return four free forces, but returning a smaller group that restores high population can remove the fourth allowance. La La La prevents the full free rate.',
'When Southern Hemisphere supports a revival that leaves Fremen at high population — including a return that first reaches the high side: the population resulting from the return decides the ability — Fremen may place the newly revived starred forces together in one territory already containing their forces, or leave them in reserves. This includes supported ordinary, Emperor-funded and Ghola revivals. Evaluating the resulting population is a visibly provisional composition policy, not a located publisher timing ruling. Basic keeps the starred counters; Advanced still limits Fedaykin revival to one per turn.',
'Under the same provisional resulting-population policy, an ordinary Free Revival that leaves Tleilax at high population may send its newly revived free group together to one permitted territory or Homeworld, or leave it in reserves. This includes the return that first reaches the high side. Paid counters in the same return and Ghola returns do not join this group. An allied Homeworld is forbidden. Placement costs no additional spice and uses no ordinary shipment or movement allowance.',
'The revival placement choice shows unavailable destinations. A return that first restores high population is now admitted under the provisional resulting-population policy above; partial groups, split destinations, Southern storm placement, allied-Homeworld limits and entry effects requiring a shipment classification remain guarded while those rules are unresolved. Independent revival income waits until placement and supported Ambassador reactions finish; refreshing does not repeat the return or payment.',
'The revival placement choice shows unavailable destinations. A return that first restores high population is admitted under the provisional resulting-population policy above. Physical Arrakis entry provisionally earns Intrusion, eligible Terror and Ambassador reactions; off-planet Tleilax placement also earns BG Spiritual Advisors. Southern placement remains on-planet. Original revival income waits for those children, including a committed Sneak Attack reserve group, without another shipment fee or allowance. Preventing a BG power leaves the revived group committed. Terror and Ambassador owners act in storm order when both qualify. Partial groups, split destinations, Southern storm placement and Hidden Mobile Stronghold classification remain guarded; allied Homeworlds remain forbidden. These SEND compositions are explicit provisional interpretations, not a new publisher FAQ.',
'While Tupile has at least eleven native forces, CHOAM cannot discard Worthless cards for spice through either ordinary sale price or a CHOAM Ambassador. Non-Worthless duplicate sales, allied trades and the Advanced CHOAM Karama cash-in retain their own rules. If a Ghola revival raises Tupile before a declared sale settles, that Worthless card stays in hand and earns no spice.',
'At the end of Bidding, high-population Kaitain lets Emperor pay two spice per selected Treachery Card to discard it. This does not play the card or begin another auction. Emperor and CHOAM share the closing opportunity, so sales, allied trades and paid discards can occur in either order. Current hands, spice and native population determine each action. Finish when ready; a new action clears readiness.',
'In supported payment cases, low Kaitain halves Emperor’s Treachery payment income and low Junction halves eligible Guild shipping income, rounded up. The payer still pays the full cost. For example, a five-spice auction payment gives low-population Emperor three spice and leaves two in the bank. The income opportunity shows the original payment, current receipt and bank remainder. A population change before collection updates the split; ordinary Karama can still prevent the separate faction income.',
@@ -4623,7 +4623,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation',
status: 'Partial',
detail:
"All 13 cards, saved typed custody, supported-deck setup, Emperor movement, native Arrakis shipment sources, revival destinations, low charity/free-revival benefits, Tleilax’s phase-start income penalty and high-Salusa free Sardaukar support are connected. World-to-world invasion, Guild transport from Arrakis and Guild native returns use exact source pools, funding and interception. Homeworld battles connect native bonuses, limited explosion casualties, native-only Traitors and Face Dancers, and Ixian substitution. Junction offers connect sponsored board and Homeworld routes, including own returns, foreign departures and their distinct payments. Caladan foresight, Wallach advisor counts, Ix stronghold movement and Richese token movement now respect native population. High Tupile blocks Worthless sales and Ambassador payouts, including after an interrupted Ghola revival. Kaitain paid discards share the closing Bidding opportunity with CHOAM; Ecaz poison income uses actual physical disposal and current native population. Mandatory winning-card disposal follows winner casualties. Supported low Kaitain and Junction payment receipts preserve the payer’s cost and use current native population; divergent allied rounding remains guarded. Giedi grants its once-per-phase bonus on actual positive desert receipts, including settled Ecaz shares. Low Grumman uses original public entry counts across Terror and competing-reaction checks. Richese High now connects actual paid reserve companions, one combined provisional invoice, original marker proof/responses, physical withdrawal and later reserve-capped reveal. Nexus Cunning retains that explicit paid group in its pair receipt, reserving paid counters before immediate materialization; original counter and spent-card outcomes remain. Ix High now returns physical Suboids for paid Cyborgs with provisional additional allowance and shortage handling, retaining typed free allocation, original invoice and free-event income. Southern and Tleilax preserve the exact eligible revival group through one optional placement, with held income and explicit unresolved destination rules. Fresh occupation additionally connects bank and percentage receipts, completed Southern Collection, immediate sharing, Caladan inspection, displaced Ix pool, Richese card-only choice with native terms, real Giedi receiver choice and native protections. Fresh native CHOAM entries additionally connect original Tupile slots, actual normal-limit cleanup and restored low-query authority without resetting usage. Advanced retains sole-qualified ownership until last departure. Basic Tupile provisionally follows current sole control: observed departure, contest or native high population ends its slot, while a new activation waits for earlier normal-limit cleanup. Continuous turns do not stack slots or reset intelligence use. Exceptional Duke, concealed transport and full/public acceptance remain unfinished. Already purchased ordinary Karama now composes with immune Junction through explicit unused-price acknowledgment; original tariff/payees stay unchanged and only delivered transport consumes its grant.",
"All 13 cards, saved typed custody, supported-deck setup, Emperor movement, native Arrakis shipment sources, revival destinations, low charity/free-revival benefits, Tleilax’s phase-start income penalty and high-Salusa free Sardaukar support are connected. World-to-world invasion, Guild transport from Arrakis and Guild native returns use exact source pools, funding and interception. Homeworld battles connect native bonuses, limited explosion casualties, native-only Traitors and Face Dancers, and Ixian substitution. Junction offers connect sponsored board and Homeworld routes, including own returns, foreign departures and their distinct payments. Caladan foresight, Wallach advisor counts, Ix stronghold movement and Richese token movement now respect native population. High Tupile blocks Worthless sales and Ambassador payouts, including after an interrupted Ghola revival. Kaitain paid discards share the closing Bidding opportunity with CHOAM; Ecaz poison income uses actual physical disposal and current native population. Mandatory winning-card disposal follows winner casualties. Supported low Kaitain and Junction payment receipts preserve the payer’s cost and use current native population; divergent allied rounding remains guarded. Giedi grants its once-per-phase bonus on actual positive desert receipts, including settled Ecaz shares. Low Grumman uses original public entry counts across Terror and competing-reaction checks. Richese High now connects actual paid reserve companions, one combined provisional invoice, original marker proof/responses, physical withdrawal and later reserve-capped reveal. Nexus Cunning retains that explicit paid group in its pair receipt, reserving paid counters before immediate materialization; original counter and spent-card outcomes remain. Ix High now returns physical Suboids for paid Cyborgs with provisional additional allowance and shortage handling, retaining typed free allocation, original invoice and free-event income. Southern and Tleilax preserve the exact eligible revival group through one optional placement. Provisional physical entry now opens native BG, Terror and Ambassador responses, retaining the original held income, independent counters and ordered overlap; a committed Sneak group has its own arrival source. Southern storm and Hidden Mobile Stronghold classification remain unresolved. Fresh occupation additionally connects bank and percentage receipts, completed Southern Collection, immediate sharing, Caladan inspection, displaced Ix pool, Richese card-only choice with native terms, real Giedi receiver choice and native protections. Fresh native CHOAM entries additionally connect original Tupile slots, actual normal-limit cleanup and restored low-query authority without resetting usage. Advanced retains sole-qualified ownership until last departure. Basic Tupile provisionally follows current sole control: observed departure, contest or native high population ends its slot, while a new activation waits for earlier normal-limit cleanup. Continuous turns do not stack slots or reset intelligence use. Exceptional Duke, concealed transport and full/public acceptance remain unfinished. Already purchased ordinary Karama now composes with immune Junction through explicit unused-price acknowledgment; original tariff/payees stay unchanged and only delivered transport consumes its grant.",
evidence: [
'game/faction-module-profile.ts',
'game/reinforcements-homeworld.ts',
+182
View File
@@ -0,0 +1,182 @@
import type { Game } from './engine';
import { gameTerritories, location, splitLocation, validLocation } from './board';
import { HomeworldCustodyError } from './homeworld-custody';
import { 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' | '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;
if (g.status !== 'playing' || !frame || frame.stage !== 'arrival' || frame.event !== parent.event ||
frame.turn !== g.turn || frame.phase !== g.phase || !text(frame.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(frame.destination);
if (frame.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, frame.destination, frame.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 !== frame.quote.normal + frame.quote.elite || details.elite !== frame.quote.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;
}
+8 -4
View File
@@ -2,6 +2,7 @@ import type { Game } from './engine';
import { TERRITORIES, validLocation } from './board';
import { HomeworldCustodyError } from './homeworld-custody';
import { TERROR_STRONGHOLDS } from './moritani-terror';
import { revivalEntryParentTuple, validRevivalEntryParent } from './revival-terror-entry';
type TerrorEntry = NonNullable<Game['pendingTerrorEntry']>;
@@ -21,6 +22,7 @@ export function terrorEntrySignature(entry: TerrorEntry): string {
entry.resume,
entry.ambassadorEvent ?? null,
...(entry.nexusPlacements ? [entry.nexusPlacements] : []),
...(entry.revivalArrival === undefined ? [] : [revivalEntryParentTuple(entry.revivalArrival)]),
]);
}
@@ -28,13 +30,14 @@ export function terrorSelectionSignature(entry: TerrorEntry): string {
return JSON.stringify(['terrorSelection', entry.candidates, entry.token, entry.entrant,
entry.territory, entry.sector, entry.amount, entry.elite, entry.cause,
entry.turn, entry.phase, entry.resume, entry.ambassadorEvent ?? null,
...(entry.nexusPlacements ? [entry.nexusPlacements] : [])]);
...(entry.nexusPlacements ? [entry.nexusPlacements] : []),
...(entry.revivalArrival === undefined ? [] : [revivalEntryParentTuple(entry.revivalArrival)])]);
}
/** 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.stage !== 'select') return;
if (entry.entrySignature === undefined && entry.candidates === undefined && entry.selectionSignature === undefined && entry.nexusPlacements === undefined && entry.revivalArrival === undefined && entry.stage !== 'select') return;
if (
typeof entry.entrySignature !== 'string' ||
typeof entry.token !== 'string' ||
@@ -68,14 +71,15 @@ export function validateTerrorEntrySignature(entry: TerrorEntry): void {
!Number.isSafeInteger(entry.phase) ||
entry.phase < 0 ||
entry.phase > 8 ||
![
(entry.revivalArrival !== undefined && !validRevivalEntryParent(entry.revivalArrival)) ||
(![
'shipment',
'movement',
'guildTransport',
'advisor',
'wormRide',
'ambassador',
].includes(entry.cause) ||
].includes(entry.cause) && !(entry.cause === 'homeworldRevival' && entry.revivalArrival !== undefined)) ||
!['none', 'wormRide', 'ambassador'].includes(entry.resume) ||
(entry.ambassadorEvent !== undefined &&
(typeof entry.ambassadorEvent !== 'string' || !entry.ambassadorEvent)) ||