From f0ebdf9c87607b5b85882e48d865cd5cde45b439 Mon Sep 17 00:00:00 2001
From: Quintine <15965533+Quintine@users.noreply.github.com>
Date: Tue, 27 Oct 2026 15:05:53 +1100
Subject: [PATCH] Preserve Caladan Sneak Attack child arrivals
---
components/game-table.tsx | 5 +-
docs/CURRENT_STATUS.md | 37 +++
docs/HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md | 53 +++++
docs/IMPLEMENTATION_STATUS.md | 19 ++
docs/RULE_DECISIONS.md | 12 +
game/arrival-overlap.ts | 20 +-
game/caladan-arrival.ts | 209 +++++++++++++++-
game/engine.ts | 115 +++++++--
game/homeworld-arrival.ts | 47 ++--
game/homeworld-victory-return.ts | 7 +-
game/reference.ts | 4 +
game/spiritual-advisor.ts | 28 ++-
game/terror-entry-receipt.ts | 9 +-
tests/caladan-sneak-engine.test.ts | 223 ++++++++++++++++++
tests/caladan-sneak-source.test.ts | 166 +++++++++++++
tests/fixture-caladan-sneak.ts | 155 ++++++++++++
16 files changed, 1065 insertions(+), 44 deletions(-)
create mode 100644 tests/caladan-sneak-engine.test.ts
create mode 100644 tests/caladan-sneak-source.test.ts
create mode 100644 tests/fixture-caladan-sneak.ts
diff --git a/components/game-table.tsx b/components/game-table.tsx
index dd867a14..d8c69407 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -2619,6 +2619,7 @@ export function GameTable({
{' '}Allow the arrival or cancel Spiritual Advisors. Canceling prevents the whole declared group.
{g.response.spiritualAdvisorSource&&<> Matching or an alone conversion may follow separately. The other faction’s shipment is already committed.>}
{g.response.spiritualAdvisorSource?.revivalEvent&&<> The revived group stays placed; this response does not cancel its revival.>}
+ {g.response.spiritualAdvisorSource?.victoryEvent&&<> The Sneak Attack group stays placed; this response does not replay or refund its arrival.>}
)}
{g.response.kind === 'moritaniRetention' &&
@@ -3830,7 +3831,9 @@ export function GameTable({
) : g.decision.kind === 'advisor' ? (
<>
- {g.decision.revivalEvent
+ {g.decision.caladanArrival
+ ? 'Moritani’s Sneak Attack group has arrived from off-planet. You may send'
+ : g.decision.revivalEvent
? 'Tleilaxu’s free-revived group has arrived from off-planet. You may send'
: 'Another faction has shipped from off-planet. You may send'}
{' '}
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 1377e4d7..ba157b15 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -20,6 +20,43 @@ open mode gates.
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
Prototype labels and final acceptance gates remain.
+**Caladan Sneak Attack child — 27 October. Prototyped / Partial.**
+A positive actual Moritani reserve group now has its own arrival after both
+owners of the original Caladan entry finish. BG’s free Spiritual opportunity,
+Basic Polar fallback and Advanced accompaniment retain separate shipping,
+matching/alone-conversion and prevention outcomes. An unconsumed Ambassador may
+react again to the new group. Zero creates no group/source; no original debit,
+victory grant or used token is replayed. Public packets use opaque UUIDs, with
+sanitized actual trigger history kept engine-owned. The ordering composition
+remains provisional. [Contract](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md#sneak-attack-child-arrival--27-october-2026).
+
+Frozen **14-file union116/116**, types/lint/build and twelve controlled runtime
+journeys pass:56 GameTable SSR and8 actual inline advisor callbacks; recorded
+battle/opening/further actions105/25/41. Actual battles and subsequent native
+Caladan/Terror/quantity decisions generate the grants from explicitly staged
+Homeworld audit setups, positions, purses, original cards/token supply/order and
+one other contest holding Battle phase. No victory/child receipt is installed.
+Six source tests separately construct signed controlled witnesses/ledger
+surrogates; they prove helper contracts and legacy bytes, not native chronology.
+
+The runtime operates Advanced away/resident-advisor accompaniment, Basic Polar,
+Polar allow/prevent conversion, shipping prevention, decline, both original
+Ambassador orders and a combined second-owner/advisor/child-Ambassador journey.
+Caladan/Moritani amount, hand-card and other reaction controls use native
+packets; those stateful controls are not operated. No browser/hydration/mobile,
+native acquisition, legal initial three-fighter stronghold Intrusion witness or
+broader Nexus/Skills/Semuta/recovery/privacy/custody/deployed certification.
+
+Feedback respected the dispatcher’s cleared decision during actual transition,
+normalized an absent optional test value and repaired two type declarations.
+Failed v1/v2 stays preserved; v3 checks one frozen source plus a final three-doc
+evidence overlay. Proof:
+`/home/quintine/.local/share/dune/checkpoints/20261027-caladan-sneak-proof-v3/`;
+journeys/HTML: adjacent `20261027-caladan-sneak-runtime/run-v2/`.
+Existing server HTTP200, without restart or saved-game/database operation.
+Full modes, remaining interactions and final acceptance remain open; full AI
+refinement stays deferred.
+
**Caladan victory arrival — 27 October. Prototyped / Partial.**
The original one-counter reserve addition now reaches a won HMS provisionally,
resolves Advanced BG Intrusion first, then opens the original Ambassador/Terror
diff --git a/docs/HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md b/docs/HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md
index 6e1e2798..a9ce99c0 100644
--- a/docs/HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md
+++ b/docs/HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md
@@ -121,6 +121,59 @@ battle cleanup and persistence. The
implemented flow and verification. No complete Homeworld, Advanced or expansion
release is claimed.
+## Sneak Attack child arrival — 27 October 2026
+
+A positive Sneak Attack spends its actual off-planet reserves once. It is a
+separate committed arrival after the Caladan addition, not a second victory
+reinforcement. Its BG Intrusion/Spiritual opportunity and native entry reactions
+follow the original Ambassador/Terror overlap, so the original second owner is
+not overwritten. A declined Ambassador may react again to the new Moritani group;
+a consumed token remains consumed. Zero places no group and creates no new
+source. Existing native quantity, occupancy, payment and token effects remain.
+
+The source classification follows the existing Sneak off-planet shipment model
+and the printed separate group. Serializing original overlap before that group's
+reactions is an explicit provisional composition, consistent with the Revival
+arrival flow; it is not a retrieved publisher sequencing example. The original
+Caladan HMS/Terror rulings above remain provisional. Caladan itself still grants
+no Spiritual Advisor shipment: only the actual positive Sneak group does.
+
+The new group's public packet uses an opaque UUID. A focused engine-only history
+records the sanitized actual trigger, selected revealed Sneak token and committed
+normal group. Private Terror candidate lists and raw entry signatures are not
+copied into public source packets. Current reaction validation uses that original
+receipt, not a new reserve quote or present inhabitants after accepted effects.
+Older original/Revival signatures stay unchanged without the new parent.
+
+Advanced BG may accompany an eligible destination or select Polar Sink; Basic
+retains its ordinary Polar fallback. Own retained fighters, matching and alone
+conversion retain their separate legality/responses. Preventing free shipping
+or a later conversion never refunds/replays the committed Sneak group. Standard
+owned controls and minimal legal policies use the same projected choices.
+
+Frozen14-file116/116, types/lint/build and twelve controlled engine/GameTable
+SSR programmes pass:56 table renders and8 actual inline advisor callbacks,
+with105/25/41 recorded battle/opening/further actions. Native packets exercise
+original Caladan/Terror/amount and cancellation controls; those stateful
+controls are not operated. The source explicitly stages audit setup, positions,
+purses, original cards/token supply/order and another contest. Actual native
+battle/Caladan/Terror/quantity actions create every victory and child receipt.
+The six controlled source/ledger-surrogate tests separately prove helper/history
+contracts and old phase4/phase6 bytes, not native chronology.
+
+The runtime covers zero/positive, both original owners, a new child Ambassador,
+Advanced away/resident-advisor accompaniment, Basic Polar, shipping prevention,
+Polar conversion allowance/prevention and decline. The combined second-owner/BG/
+child journey preserves the original prompt. No legal initial three-fighting-
+faction stronghold source is invented to claim Intrusion chronology. Source
+feedback repaired the dispatcher-transition assumption, optional test absence
+and two type declarations; failed v1/v2 remains preserved.
+Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-caladan-sneak-proof-v3/`;
+journeys/HTML: adjacent `20261027-caladan-sneak-runtime/run-v2/`.
+No restart or saved-game/database operation. Browser/hydration/mobile, native
+acquisition, deeper Nexus/Skills/Semuta combinations, native full games and final
+assurance/deployed acceptance remain open.
+
## Focused before-Face-Dance evidence — 26 October 2026
Frozen15-file189/189, types/lint/build and sixteen engine/GameTable SSR journeys
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index f8f9aef2..33d8cb85 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,24 @@
# Dune implementation status
+## 27 October 2026 — Caladan Sneak Attack child arrival
+
+A positive actual Sneak group now receives a separate opaque source, after the
+original Ambassador/Terror owners finish. Basic Polar/Advanced accompaniment,
+independent shipping and conversion responses, a new eligible Ambassador and
+minimal policies retain the one committed reserve group. Zero creates no source.
+Engine-only sanitized history avoids publishing private Terror candidate lists.
+The adopted source order remains provisional.
+
+Frozen14-file116/116, types/lint/build and twelve controlled runtime programmes
+pass (56 table SSR,8 actual advisor callbacks;105/25/41 battle/opening/further
+actions). Controlled setup and exact native-action boundaries are explicit;
+six signed source witnesses/ledger surrogates are helper evidence. Failed v1/v2
+stays; v3 binds frozen source plus final three-document overlay. Existing server
+HTTP200 without restart/saved-game/database operation. [Current checkpoint](CURRENT_STATUS.md#current-checkpoint-and-work),
+[contract/proof and limits](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md#sneak-attack-child-arrival--27-october-2026).
+No native acquisition, full-mode, broader combination, browser/deployed or final
+assurance certification; full AI refinement remains deferred.
+
## 27 October 2026 — Caladan victory arrival
One actual reserve counter now reaches a won HMS provisionally and resolves
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 18a68b9a..94f7c8c0 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -1,5 +1,17 @@
# Rule decision index
+## Caladan-triggered Sneak Attack child — 27 October 2026
+
+A positive actual Sneak group receives its own off-planet arrival source, after
+both owners of the original Caladan entry finish. Under standing recommended-
+option authority, this ordering is provisional composition of the existing
+Sneak/Spiritual entry model, not a specific publisher example. Zero has no new
+source. An unconsumed Ambassador may react to that separate group, while used
+physical tokens and the original single reserve addition remain spent. Basic
+Polar and Advanced accompaniment retain native permissions and independent
+Karama/conversion responses. Public source packets use opaque events; sanitized
+trigger history stays engine-owned. [Contract](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md#sneak-attack-child-arrival--27-october-2026).
+
## Caladan victory placement and entry — 27 October 2026
Compose the original Caladan high face's one-counter addition with Advanced
diff --git a/game/arrival-overlap.ts b/game/arrival-overlap.ts
index 19a95274..57033f00 100644
--- a/game/arrival-overlap.ts
+++ b/game/arrival-overlap.ts
@@ -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));
}
diff --git a/game/caladan-arrival.ts b/game/caladan-arrival.ts
index b42e1032..cc17d9e2 100644
--- a/game/caladan-arrival.ts
+++ b/game/caladan-arrival.ts
@@ -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;
@@ -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 & {
+ 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 & { caladanArrival?: CaladanEntryParent };
+const record = (value: unknown): value is Record =>
+ !!value && typeof value === 'object' && !Array.isArray(value);
+const text = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0;
+const count = (value: unknown, maximum = 20): value is number =>
+ Number.isSafeInteger(value) && (value as number) >= 0 && (value as number) <= maximum;
+function exact(value: unknown, names: readonly string[]): value is Record {
+ return record(value) && Object.keys(value).length === names.length && names.every(key => Object.hasOwn(value, key));
+}
+function fail(): never {
+ throw new HomeworldCustodyError('The 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): 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): 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();
+ 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 };
+}
diff --git a/game/engine.ts b/game/engine.ts
index d147c101..f818228a 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -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['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['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,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,
}
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['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);
diff --git a/game/homeworld-arrival.ts b/game/homeworld-arrival.ts
index 2ca5f85c..11f3f44d 100644
--- a/game/homeworld-arrival.ts
+++ b/game/homeworld-arrival.ts
@@ -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');
diff --git a/game/homeworld-victory-return.ts b/game/homeworld-victory-return.ts
index ce68dfbc..e0597855 100644
--- a/game/homeworld-victory-return.ts
+++ b/game/homeworld-victory-return.ts
@@ -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 !['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, 'event' | 'turn' | 'phase' | 'stage' | 'destination'>;
- homeworldVictoryReinforcement?: Pick, 'event' | 'turn' | 'phase' | 'stage'>;
+ homeworldVictoryReinforcement?: Pick, 'event' | 'turn' | 'phase' | 'stage'> &
+ Partial, 'player' | 'destination' | 'terrorArrivals'>>;
pendingSpiritualAdvisor?: Pick, 'event' | 'turn' | 'phase'> & {
- decision: Pick['decision'], 'player' | 'destination' | 'ambassadorEvent' | 'revivalEvent'>;
+ decision: Pick['decision'], 'player' | 'destination' | 'ambassadorEvent' | 'revivalEvent'> & {shipment?:string;caladanArrival?:CaladanEntryParent};
};
pendingAmbassador?: Pick,
'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 &&
diff --git a/game/terror-entry-receipt.ts b/game/terror-entry-receipt.ts
index 9be3e20a..61e20cd0 100644
--- a/game/terror-entry-receipt.ts
+++ b/game/terror-entry-receipt.ts
@@ -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;
@@ -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)) ||
(![
diff --git a/tests/caladan-sneak-engine.test.ts b/tests/caladan-sneak-engine.test.ts
new file mode 100644
index 00000000..14195aca
--- /dev/null
+++ b/tests/caladan-sneak-engine.test.ts
@@ -0,0 +1,223 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { applyAction, viewGame, type Action, type Game } from '../game/engine';
+import { botActions } from '../game/bots';
+import { isAdvisor } from '../game/advisors';
+import {
+ createCaladanSneakFixture as fixture, openCaladanSneak as open,
+ commitCaladanSneak as commit, actCaladanSneak as act,
+ settleCaladanSneak as settle, passCaladanSneakResponse as pass,
+ allowCaladanSneakResponses as allow, caladanSneakPlayer as p,
+ caladanSneakInventory as inventory, caladanSneakReload as reload,
+} from './fixture-caladan-sneak';
+
+function reject(game: Game, actor: string, action: Action) {
+ const before = reload(game);
+ assert.throws(() => applyAction(game,actor,action));
+ assert.deepEqual(game,before);
+}
+function complete(game: Game, event: string) {
+ assert.equal(game.homeworldVictoryReinforcement!.event,event);
+ assert.equal(game.homeworldVictoryReinforcement!.stage,'complete');
+ assert.equal(game.lastBattleContext!.caladanReinforcement!.completed,true);
+ assert.equal(p(game,'a').reserves,5);
+ assert.equal(p(game,'a').forces['arrakeen:10'],3);
+ inventory(game);
+}
+function declineChildAmbassador(game: Game) {
+ const entry = game.pendingAmbassador;
+ assert.ok(entry);
+ return act(game,'ec',{type:'decision',event:entry.event,decline:true});
+}
+
+void test('Caladan positive Sneak commits one distinct reserve group with one actual original receipt', () => {
+ const f = fixture({bg:'none'}), opened = open(f);
+ assert.match(f.disclosure,/Controlled.*no installed victory or child receipt/);
+ assert.equal(f.sourceCount,f.sourceActions.length);
+ assert.equal(f.sourceActions.filter(s => s.action.type === 'battlePlan').length,2);
+ assert.equal(f.sourceActions.filter(s => s.action.type === 'traitorCall').length,2);
+ assert.equal(f.source.homeworldVictoryReinforcement,undefined);
+ const before = reload(opened.game);
+ const game = settle(act(opened.game,'m',{type:'decision',amount:1}));
+ assert.equal(p(game,'m').reserves,p(before,'m').reserves-1);
+ assert.equal(p(game,'m').forces['arrakeen:10'],1);
+ for (const id of ['a','m']) for (const field of ['spice','shipped','moved'] as const)
+ assert.deepEqual(p(game,id)[field],p(before,id)[field]);
+ const receipts = game.homeworldVictoryReinforcement!.terrorArrivals!;
+ assert.equal(receipts.length,1);
+ assert.equal(receipts[0].packet.owner,'m');
+ assert.equal(receipts[0].packet.territory,'arrakeen');
+ assert.equal(receipts[0].packet.sector,10);
+ assert.equal(receipts[0].packet.amount,1);
+ assert.equal(receipts[0].packet.elite,0);
+ assert.notEqual(receipts[0].packet.event,f.event);
+ assert.equal(game.moritaniTerror!.tokens.find(t => t.id === f.token)!.status,'removed');
+ complete(game,f.event);
+ reject(game,'a',f.action);
+});
+
+void test('Caladan zero Sneak spends no reserve and creates no child or free advisor source', () => {
+ const f = fixture({bg:'away'}), before = open(f);
+ const game = settle(act(before.game,'m',{type:'decision',amount:0}));
+ assert.equal(p(game,'m').reserves,p(before.game,'m').reserves);
+ assert.equal(p(game,'m').forces['arrakeen:10'],undefined);
+ assert.equal(game.homeworldVictoryReinforcement!.terrorArrivals?.length ?? 0,0);
+ assert.equal(game.pendingSpiritualAdvisor,undefined);
+ assert.equal(game.pendingArrivalReaction?.length ?? 0,0);
+ assert.equal(game.pendingAmbassador??null,null);
+ complete(game,f.event);
+});
+
+for (const order of ['first','second'] as const)
+ void test(`Caladan Sneak preserves the original Ambassador ${order} before its second child offer`, () => {
+ const f = fixture({bg:'none',ambassador:order}), opened = open(f);
+ const original = structuredClone(opened.game.pendingArrivalOverlap);
+ assert.ok(original);
+ assert.equal(original.victoryEvent,f.event);
+ let game = act(opened.game,'m',{type:'decision',amount:1});
+ if (order === 'second') {
+ assert.equal(game.pendingAmbassador?.entrant,'a');
+ assert.equal(game.pendingAmbassador?.event,game.pendingArrivalOverlap?.ambassadorEvent);
+ assert.equal(game.pendingArrivalOverlap?.event,original.event);
+ assert.equal(game.homeworldVictoryReinforcement!.stage,'arrival');
+ const queued = game.pendingArrivalReaction!.find(entry => entry.entrant === 'm')!;
+ assert.ok(queued);
+ assert.equal(queued.caladanSneakAdvisor,true);
+ assert.equal(queued.caladanArrival?.event,f.event);
+ assert.equal(queued.caladanArrival?.terror?.owner,'m');
+ game = declineChildAmbassador(game);
+ }
+ assert.equal(game.pendingArrivalOverlap,null);
+ assert.equal(game.pendingAmbassador?.entrant,'m');
+ assert.equal(game.pendingAmbassador?.token,f.ambassadorToken);
+ assert.equal(game.pendingAmbassador?.victoryEvent,f.event);
+ const records = game.homeworldVictoryReinforcement!.ambassadors!;
+ assert.equal(records.length,2);
+ assert.deepEqual(records.map(record => record.completed),[true,false]);
+ assert.equal(records[0].entrant,'a');
+ assert.equal(records[1].entrant,'m');
+ assert.equal(records[1].terror?.event,game.homeworldVictoryReinforcement!.terrorArrivals![0].packet.event);
+ assert.notEqual(records[0].event,records[1].event);
+ reject(game,'a',f.action);
+ game = settle(declineChildAmbassador(game));
+ assert.deepEqual(game.homeworldVictoryReinforcement!.ambassadors!.map(record => record.completed),[true,true]);
+ assert.equal(p(game,'m').reserves,19);
+ assert.equal(p(game,'m').forces['arrakeen:10'],1);
+ complete(game,f.event);
+ });
+
+void test('Caladan Sneak Advanced accompaniment uses its own committed source with away or resident advisors', () => {
+ for (const bg of ['away','advisors'] as const) {
+ const f = fixture({bg}), committed = commit(f);
+ let game = committed.game;
+ assert.equal(game.decision?.kind,'advisor');
+ assert.ok(game.decision?.kind === 'advisor' && game.decision.caladanArrival);
+ if (game.decision?.kind !== 'advisor') assert.fail('The committed Sneak group owns its advisor offer.');
+ const parent = game.decision.caladanArrival!;
+ assert.equal(parent.event,f.event);
+ assert.equal(parent.terror?.owner,'m');
+ assert.equal(parent.terror?.amount,1);
+ const before = reload(game), original = structuredClone(game.homeworldVictoryReinforcement!.terrorArrivals);
+ game = act(game,'bg',{type:'decision',accept:true,accompany:true});
+ assert.equal(game.response?.kind,'advisor');
+ assert.equal(game.response?.spiritualAdvisorSource?.phase,6);
+ assert.equal(game.response?.spiritualAdvisorSource?.victoryEvent,f.event);
+ assert.deepEqual(game.pendingSpiritualAdvisor!.decision.caladanArrival,parent);
+ assert.equal(game.homeworldVictoryReinforcement!.stage,'arrival');
+ assert.equal(p(game,'bg').reserves,p(before,'bg').reserves);
+ game = allow(game);
+ assert.equal(p(game,'bg').forces['arrakeen:10'],bg === 'advisors' ? 2 : 1);
+ assert.equal(p(game,'bg').reserves,p(before,'bg').reserves-1);
+ assert.equal(isAdvisor(p(game,'bg'),'arrakeen'),true);
+ assert.deepEqual(game.homeworldVictoryReinforcement!.terrorArrivals,original);
+ complete(game,f.event);
+ }
+});
+
+for (const prevented of [false,true])
+ void test(`Caladan Sneak Polar arrival ${prevented ? 'prevents' : 'allows'} the distinct automatic conversion`, () => {
+ const f = fixture({bg:'away',polarAlone:true}), committed = commit(f);
+ let game = act(committed.game,'bg',{type:'decision',accept:true,accompany:false});
+ assert.equal(game.pendingSpiritualAdvisor?.stage,'shipping');
+ game = pass(game);
+ assert.equal(game.response?.automaticAdvisorFlip?.producer,'spiritualArrival');
+ assert.equal(game.pendingSpiritualAdvisor?.stage,'arrival');
+ assert.equal(p(game,'bg').forces['polar_sink:0'],1);
+ assert.equal(isAdvisor(p(game,'bg'),'polar_sink'),true);
+ const physical = structuredClone(p(game,'bg'));
+ game = prevented ? act(game,'g',{type:'card',card:f.karama,mode:'cancel'}) : pass(game);
+ game = settle(game);
+ assert.equal(isAdvisor(p(game,'bg'),'polar_sink'),prevented);
+ assert.equal(p(game,'bg').forces['polar_sink:0'],physical.forces['polar_sink:0']);
+ assert.equal(p(game,'bg').reserves,physical.reserves);
+ assert.equal(p(game,'m').forces['arrakeen:10'],1);
+ assert.equal(game.discard.filter(c => c.id === f.karama).length,Number(prevented));
+ assert.equal(game.homeworldVictoryReinforcement!.terrorArrivals!.length,1);
+ complete(game,f.event);
+ });
+
+void test('Caladan Sneak shipping prevention leaves the committed group and original cost without replay or refund', () => {
+ const f = fixture(), committed = commit(f);
+ const before = reload(committed.game);
+ let game = act(committed.game,'bg',{type:'decision',accept:true,accompany:true});
+ assert.equal(game.response?.kind,'advisor');
+ game = settle(act(game,'g',{type:'card',card:f.karama,mode:'cancel'}));
+ assert.equal(p(game,'bg').reserves,p(before,'bg').reserves);
+ assert.equal(p(game,'bg').forces['arrakeen:10'],undefined);
+ assert.equal(p(game,'m').forces['arrakeen:10'],1);
+ assert.equal(p(game,'m').reserves,19);
+ assert.equal(game.discard.filter(c => c.id === f.karama).length,1);
+ assert.equal(game.homeworldVictoryReinforcement!.terrorArrivals!.length,1);
+ complete(game,f.event);
+ reject(game,'bg',{type:'decision',accept:true,accompany:true});
+});
+
+void test('Caladan Sneak decline consumes only the original optional advisor choice', () => {
+ const f = fixture(), committed = commit(f), before = structuredClone(p(committed.game,'bg'));
+ const game = settle(act(committed.game,'bg',{type:'decision',accept:false}));
+ assert.deepEqual(p(game,'bg'),before);
+ assert.ok(p(game,'g').hand.some(c => c.id === f.karama));
+ assert.equal(game.pendingSpiritualAdvisor,undefined);
+ assert.equal(game.homeworldVictoryReinforcement!.terrorArrivals!.length,1);
+ complete(game,f.event);
+});
+
+void test('Caladan Sneak Basic fallback uses the legal original free Polar shipment', () => {
+ const f = fixture({advanced:false}), committed = commit(f), before = reload(committed.game);
+ let game = act(committed.game,'bg',{type:'decision',accept:true,accompany:false});
+ assert.equal(game.response?.spiritualAdvisorSource?.victoryEvent,f.event);
+ game = allow(game);
+ assert.equal(p(game,'bg').forces['polar_sink:0'],1);
+ assert.equal(p(game,'bg').reserves,p(before,'bg').reserves-1);
+ assert.equal(isAdvisor(p(game,'bg'),'polar_sink'),false);
+ assert.equal(p(game,'bg').spice,p(before,'bg').spice);
+ complete(game,f.event);
+});
+
+void test('Caladan Sneak keeps native owner and amount validation while the original choice is busy', () => {
+ const f = fixture(), opened = open(f);
+ reject(opened.game,'a',{type:'decision',amount:1});
+ reject(opened.game,'m',{type:'decision',amount:-1});
+ reject(opened.game,'a',f.action);
+ const committed = commit(f);
+ reject(committed.game,'a',{...f.action,event:`${f.event}:stale`});
+ reject(committed.game,'g',{type:'decision',accept:true,accompany:true});
+ assert.equal(p(committed.game,'m').reserves,19);
+ assert.equal(committed.game.homeworldVictoryReinforcement!.terrorArrivals!.length,1);
+ inventory(committed.game);
+});
+
+void test('all four minimal profiles can answer the actual Caladan Sneak advisor opportunity legally', () => {
+ const f = fixture(), committed = commit(f);
+ for (const profile of ['Easy','Medium','Hard','Brutal'] as const) {
+ let game = reload(committed.game);
+ const own = viewGame(game,'bg');
+ own.players.find(player => player.id === 'bg')!.bot = profile;
+ const action = botActions(own)[0];
+ assert.ok(action,`${profile} needs a legal original advisor choice.`);
+ game = allow(act(game,'bg',action));
+ assert.equal(game.homeworldVictoryReinforcement!.terrorArrivals!.length,1);
+ assert.equal(p(game,'m').reserves,19);
+ complete(game,f.event);
+ }
+});
diff --git a/tests/caladan-sneak-source.test.ts b/tests/caladan-sneak-source.test.ts
new file mode 100644
index 00000000..581deab8
--- /dev/null
+++ b/tests/caladan-sneak-source.test.ts
@@ -0,0 +1,166 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createGame, newPlayer, type Game } from '../game/engine';
+import { createHomeworldCustody, HomeworldCustodyError } from '../game/homeworld-custody';
+import { homeworldContext } from '../game/homeworld-game';
+import { createTerrorState } from '../game/moritani-terror';
+import { terrorEntrySignature, terrorSelectionSignature } from '../game/terror-entry-receipt';
+import {
+ makeHomeworldVictoryReturn, homeworldVictoryOfferSignature, homeworldVictoryObligationSignature,
+} from '../game/homeworld-victory-return';
+import {
+ appendHomeworldArrivalAmbassador, completeHomeworldArrivalAmbassador, homeworldArrivalSignature,
+ validateHomeworldArrival, type HomeworldArrivalFrame,
+} from '../game/homeworld-arrival';
+import {
+ createCaladanSneakReceipt, validateCaladanSneakHistory, validateCaladanTerrorPacket,
+ validCaladanEntryParent, caladanEntryParentTuple, validateCaladanChildArrival,
+ type CaladanEntryParent, type CaladanTerrorHistoryContext,
+} from '../game/caladan-arrival';
+
+const uuid = '00112233-4455-4677-8899-aabbccddeeff';
+const otherUUID = '11112233-4455-4677-8899-aabbccddeeff';
+
+/** Controlled provenance unit: signed victory/arrival records, selected removed
+ * Sneak token and private stacked candidates are scenario inputs. This does not
+ * claim a native battle, placement, reserve debit or second-token chronology. */
+function fixture() {
+ const g = createGame('CALADANSNEAKSOURCE', newPlayer('a', 'Atreides', 'atreides'), true, ['ecaz']);
+ g.players.push(newPlayer('m', 'Moritani', 'moritani'), newPlayer('bg', 'BG', 'beneGesserit'), newPlayer('ec', 'Ecaz', 'ecaz'));
+ Object.assign(g, { status: 'playing', turn: 2, phase: 6 });
+ Object.assign(g.players[0], { reserves: 5, tanks: 12, forces: { 'arrakeen:10': 3 } });
+ g.homeworlds = { custody: createHomeworldCustody(homeworldContext(g)) };
+ const context = { event: 'original-victory', turn: 2, territory: 'arrakeen', winner: 'a', result: 'normal' } as NonNullable;
+ const frame = makeHomeworldVictoryReturn({ event: context.event, turn: 2, player: 'a', territory: 'arrakeen', result: 'normal' });
+ frame.stage = 'arrival'; frame.destination = 'arrakeen:10'; frame.ambassadors = [];
+ frame.offer = { population: 6, survivors: 2, blocked: null,
+ signature: homeworldVictoryOfferSignature(frame, { population: 6, survivors: 2, blocked: null }) };
+ frame.arrivalSignature = homeworldArrivalSignature(frame);
+ context.caladanReinforcement = { event: frame.event, completed: false, stage: 'arrival',
+ signature: homeworldVictoryObligationSignature(context, false, 'arrival') };
+ g.lastBattleContext = context; g.homeworldVictoryReinforcement = frame;
+ g.moritaniTerror = createTerrorState(() => 0);
+ const token = g.moritaniTerror.tokens.find(candidate => candidate.kind === 'sneakAttack')!;
+ token.status = 'removed'; token.location = null;
+ const hidden = g.moritaniTerror.tokens.filter(candidate => candidate.id !== token.id).slice(0, 2);
+ for (const candidate of hidden) { candidate.status = 'placed'; candidate.location = 'arrakeen'; }
+ const entry: NonNullable = { token: token.id,
+ candidates: [token.id, ...hidden.map(candidate => candidate.id)], entrant: 'a', territory: 'arrakeen',
+ sector: 10, amount: 1, elite: 0, cause: 'caladanReinforcement', victoryEvent: frame.event,
+ turn: 2, phase: 6, stage: 'sneakAttack', resume: 'none' };
+ entry.selectionSignature = terrorSelectionSignature(entry); entry.entrySignature = terrorEntrySignature(entry);
+ g.pendingTerrorEntry = entry; g.decision = { kind: 'moritaniTerror', player: 'm', entrant: 'a', territory: 'arrakeen' };
+ return { g, frame, entry, hidden: hidden.map(candidate => candidate.id) };
+}
+function committed(amount = 2) {
+ const f = fixture(), result = createCaladanSneakReceipt(f.g, f.entry, 'm', amount, uuid);
+ // Controlled ledger append represents the caller's post-transfer step. This
+ // unit does not claim to have operated the native transfer itself.
+ f.frame.terrorArrivals = [result.receipt];
+ return { ...f, ...result };
+}
+function history(f: ReturnType): CaladanTerrorHistoryContext {
+ return { turn: f.frame.turn, phase: 6, players: f.g.players, victoryPlayer: f.frame.player,
+ terrorArrivals: f.frame.terrorArrivals! };
+}
+
+void test('Caladan Sneak quotes an opaque public packet and sanitized used-token history without private stack identities', () => {
+ const f = fixture(), before = structuredClone(f.g);
+ const result = createCaladanSneakReceipt(f.g, f.entry, 'm', 2, uuid);
+ assert.deepEqual(result.packet, { event: uuid, owner: 'm', territory: 'arrakeen', sector: 10, amount: 2, elite: 0 });
+ assert.notEqual(result.packet.event, f.entry.entrySignature);
+ assert.equal(result.receipt.token, f.entry.token);
+ assert.deepEqual(Object.keys(result.receipt.trigger).sort(), ['amount', 'cause', 'elite', 'entrant', 'sector', 'territory', 'victoryEvent']);
+ for (const id of f.hidden) {
+ assert.ok(!JSON.stringify(result.packet).includes(id));
+ assert.ok(!JSON.stringify(result.receipt).includes(id));
+ }
+ assert.doesNotMatch(JSON.stringify(result.receipt), /candidates|selectionSignature|entrySignature|"players"|"hand"/);
+ assert.notEqual(result.packet, result.receipt.packet);
+ assert.deepEqual(f.g, before);
+ f.g.decision = null; // applyAction clears its owned decision during dispatch.
+ assert.doesNotThrow(() => createCaladanSneakReceipt(f.g, f.entry, 'm', 2, otherUUID));
+});
+
+void test('the current selected Sneak stage owner source and positive packet count remain required before debit', () => {
+ for (const variant of ['owner', 'zero', 'oversize', 'raw-event', 'stage', 'source'] as const) {
+ const f = fixture();
+ const entry = variant === 'source' ? { ...f.entry, amount: 2 } : f.entry;
+ if (variant === 'stage') f.entry.stage = 'offer';
+ const before = structuredClone(f.g);
+ assert.throws(() => createCaladanSneakReceipt(f.g, entry, variant === 'owner' ? 'ec' : 'm',
+ variant === 'zero' ? 0 : variant === 'oversize' ? 6 : 2,
+ variant === 'raw-event' ? f.entry.entrySignature! : uuid), HomeworldCustodyError);
+ assert.deepEqual(f.g, before);
+ }
+});
+
+void test('opaque history validates its original source after resources token custody and the latest battle have changed', () => {
+ const f = committed();
+ validateCaladanTerrorPacket(history(f), f.packet, f.frame.destination!, f.frame.event);
+ validateCaladanSneakHistory(f.g);
+ f.g.players.find(player => player.id === 'm')!.reserves = 0;
+ f.g.players[0].forces = {};
+ f.g.moritaniTerror!.tokens = [];
+ f.frame.stage = 'complete'; f.g.lastBattleContext = undefined; f.g.turn = 3; f.g.phase = 8;
+ assert.doesNotThrow(() => validateCaladanSneakHistory(f.g));
+ const modified = { ...f.packet, amount: 3 };
+ assert.throws(() => validateCaladanTerrorPacket(history(f), modified, f.frame.destination!, f.frame.event), HomeworldCustodyError);
+ assert.throws(() => validateCaladanTerrorPacket(history(f), f.packet, 'carthag:11', f.frame.event), HomeworldCustodyError);
+});
+
+void test('new Moritani and Spiritual child entries bind their own packets and cannot reuse the original Caladan force', () => {
+ const f = committed(), parent: CaladanEntryParent = { event: f.frame.event, terror: f.packet };
+ const direct = { entrant: 'm', territory: 'arrakeen', sector: 10, amount: 2, elite: 0, cause: 'shipment' };
+ assert.ok(validCaladanEntryParent(parent));
+ validateCaladanChildArrival(f.g, parent, direct);
+ assert.throws(() => validateCaladanChildArrival(f.g, parent, { ...direct, entrant: 'a', amount: 1, cause: 'caladanReinforcement' }), HomeworldCustodyError);
+ const spiritual: CaladanEntryParent = { ...parent, spiritual: { event: 'actual-bg-packet', owner: 'bg', turn: 2,
+ phase: 6, territory: 'polar_sink', sector: 0, amount: 1, victoryEvent: f.frame.event } };
+ const advisor = { entrant: 'bg', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, cause: 'advisor' };
+ validateCaladanChildArrival(f.g, spiritual, advisor);
+ assert.deepEqual(caladanEntryParentTuple(spiritual), ['caladanArrival', f.frame.event,
+ ['actual-bg-packet', 'bg', 2, 6, 'polar_sink', 0, 1, f.frame.event],
+ [uuid, 'm', 'arrakeen', 10, 2, 0]]);
+ assert.equal(validCaladanEntryParent({ event: f.frame.event, spiritual: spiritual.spiritual }), false);
+ assert.equal(validCaladanEntryParent({ ...parent, spiritual: undefined }), false);
+ assert.throws(() => validateCaladanChildArrival(f.g, spiritual, { ...advisor, amount: 2 }), HomeworldCustodyError);
+ assert.throws(() => validateCaladanChildArrival(f.g, { ...parent, terror: { ...f.packet, event: otherUUID } }, direct), HomeworldCustodyError);
+});
+
+void test('phase-six Ambassador history resolves opaque Caladan packets and their Spiritual roots without storing private stack signatures', () => {
+ const f = committed();
+ const entry: NonNullable = { event: 'sneak-ambassador', owner: 'ec', entrant: 'm',
+ territory: 'arrakeen', sector: 10, turn: 2, phase: 6, victoryEvent: f.frame.event,
+ token: 'controlled-ambassador-token', stage: 'offer', copyChoices: [], resume: 'none' };
+ appendHomeworldArrivalAmbassador(f.frame, entry, undefined, undefined, f.packet);
+ f.g.pendingAmbassador = entry;
+ validateHomeworldArrival(f.g, f.frame, 'victoryEvent');
+ completeHomeworldArrivalAmbassador(f.frame, entry.event); f.g.pendingAmbassador = null;
+ const source = { event: 'spiritual-packet', owner: 'bg', turn: 2, phase: 6,
+ territory: 'polar_sink', sector: 0, amount: 1, victoryEvent: f.frame.event };
+ const bg = { ...entry, event: 'spiritual-ambassador', entrant: 'bg', territory: 'polar_sink', sector: 0 };
+ appendHomeworldArrivalAmbassador(f.frame, bg, undefined, source, f.packet); f.g.pendingAmbassador = bg;
+ validateHomeworldArrival(f.g, f.frame, 'victoryEvent');
+ for (const id of f.hidden) assert.ok(!f.frame.arrivalSignature!.includes(id));
+ assert.ok(!f.frame.arrivalSignature!.includes(f.entry.entrySignature!));
+});
+
+void test('legacy phase-four Spiritual bytes and phase-six Guild child bytes stay unchanged', () => {
+ const old: HomeworldArrivalFrame = { event: 'old-return', turn: 2, phase: 4, player: 't', stage: 'arrival',
+ signature: 'old-original-signature', destination: 'arrakeen:10', ambassadors: [] };
+ old.arrivalSignature = homeworldArrivalSignature(old);
+ const source = { event: 'old-spiritual', owner: 'bg', turn: 2, phase: 4, territory: 'arrakeen', sector: 10,
+ amount: 1, revivalEvent: old.event };
+ appendHomeworldArrivalAmbassador(old, { event: 'old-ambassador', entrant: 'bg', territory: 'arrakeen', sector: 10,
+ turn: 2, phase: 4 }, undefined, source);
+ assert.equal(old.arrivalSignature, JSON.stringify(['homeworldRevivalArrival', old.signature, old.destination,
+ [['old-ambassador', 'bg', 'arrakeen:10', null, false, ['old-spiritual', 'bg', 2, 4, 'arrakeen', 10, 1, old.event]]]]));
+ const guild: HomeworldArrivalFrame = { event: 'old-victory', turn: 2, phase: 6, player: 'a', stage: 'arrival',
+ signature: 'old-victory-signature', destination: 'arrakeen:10', ambassadors: [
+ { event: 'guild-parent', entrant: 'a', destination: 'arrakeen:10', completed: true },
+ { event: 'guild-bg-child', entrant: 'bg', destination: 'polar_sink:0', parent: 'guild-parent', completed: false },
+ ] };
+ assert.equal(homeworldArrivalSignature(guild), JSON.stringify(['homeworldRevivalArrival', guild.signature, guild.destination,
+ [['guild-parent', 'a', 'arrakeen:10', null, true], ['guild-bg-child', 'bg', 'polar_sink:0', 'guild-parent', false]]]));
+});
diff --git a/tests/fixture-caladan-sneak.ts b/tests/fixture-caladan-sneak.ts
new file mode 100644
index 00000000..bda75a43
--- /dev/null
+++ b/tests/fixture-caladan-sneak.ts
@@ -0,0 +1,155 @@
+import assert from 'node:assert/strict';
+import { viewGame, type Action, type Game } from '../game/engine';
+import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors';
+import { placeTerror } from '../game/moritani-terror';
+import { location, territory } from '../game/board';
+import { caladanVictoryFixture, holdVictoryCard } from './fixture-caladan-victory';
+import {
+ actCaladanArrival, caladanArrivalInventory, caladanArrivalPlayer,
+ caladanArrivalReload, type CaladanArrivalStep,
+} from './fixture-caladan-arrival';
+import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
+
+export const actCaladanSneak = actCaladanArrival;
+export const caladanSneakPlayer = caladanArrivalPlayer;
+export const caladanSneakReload = caladanArrivalReload;
+export const caladanSneakInventory = caladanArrivalInventory;
+export type CaladanSneakStep = CaladanArrivalStep;
+
+/** Neutral native controls only. All entry decisions and the current original
+ * reinforcement remain owned by the caller; no later battle is started. */
+export function settleCaladanSneak(game: Game, actions: CaladanSneakStep[] = []): Game {
+ let g = game;
+ for (let n = 0; n < 120; n++) {
+ if (g.homeworldVictoryReinforcement?.stage === 'complete') return g;
+ if (g.decision && ['caladanReinforcement','moritaniTerror','ecazAmbassador',
+ 'intrusion','advisor','arrivalOverlap'].includes(g.decision.kind)) return g;
+ if (!g.battle && !g.response && !g.pendingTreacheryDiscard) return g;
+ const next = nextStrongholdFactionsNativeStep(g);
+ if (!next) return g;
+ g = actCaladanSneak(g,next.actor,next.action,actions);
+ }
+ assert.fail('The bounded Caladan Sneak continuation did not stop.');
+}
+
+/** Finish exactly the present preventable layer; a later matching/automatic
+ * advisor conversion remains observable instead of being folded into it. */
+export function passCaladanSneakResponse(game: Game, actions: CaladanSneakStep[] = []): Game {
+ let g = game;
+ const signature = (state: Game) => state.response ? JSON.stringify([
+ state.response.kind, state.response.spiritualAdvisorSource,
+ state.response.automaticAdvisorFlip,
+ ]) : null;
+ const original = signature(g);
+ assert.ok(original);
+ for (let n = 0; signature(g) === original && n < 40; n++) {
+ const actor = g.players.find(p => !g.response!.passed.includes(p.id));
+ assert.ok(actor);
+ g = actCaladanSneak(g,actor.id,{type:'passResponse'},actions);
+ }
+ assert.notEqual(signature(g),original);
+ return g;
+}
+
+export function allowCaladanSneakResponses(game: Game, actions: CaladanSneakStep[] = []): Game {
+ let g = game;
+ for (let n = 0; g.response && n < 40; n++) g = passCaladanSneakResponse(g,actions);
+ assert.equal(g.response,null);
+ return settleCaladanSneak(g,actions);
+}
+
+export function createCaladanSneakFixture(options: {
+ advanced?: boolean; bg?: 'none'|'away'|'advisors';
+ ambassador?: 'first'|'second'; polarAlone?: boolean;
+} = {}) {
+ const advanced = options.advanced ?? true, bg = options.bg ?? 'away';
+ let game = caladanVictoryFixture({advanced,extraSeats:[
+ {id:'m',faction:'moritani'},
+ ...(options.ambassador ? [{id:'ec',faction:'ecaz'} as const] : []),
+ ...(bg !== 'none' ? [{id:'bg',faction:'beneGesserit'} as const] : []),
+ ]});
+ // Explicit controlled source seam: the original Homeworld preview setup is
+ // genuine, while these later positions/purses/card custody and token placement
+ // are conserved scenario inputs, not an acquisition or movement history.
+ game.expansions.push('ecaz');
+ for (const id of ['a','g']) {
+ const p = caladanSneakPlayer(game,id);
+ delete p.forces['hagga_basin:12']; p.forces['arrakeen:10'] = 3;
+ }
+ const a = caladanSneakPlayer(game,'a'), guild = caladanSneakPlayer(game,'g');
+ if (options.polarAlone) {
+ a.forces['hagga_basin:12'] = a.forces['polar_sink:0'];
+ delete a.forces['polar_sink:0'];
+ }
+ if (bg !== 'none') {
+ const p = caladanSneakPlayer(game,'bg');
+ Object.assign(p,{reserves:19,forces:bg === 'advisors' ? {'arrakeen:10':1} : {'sietch_tabr:14':1},
+ advisors:advanced && bg === 'advisors' ? {arrakeen:{}} : {}});
+ }
+ const token = game.moritaniTerror!.tokens.find(t => t.kind === 'sneakAttack')!.id;
+ game.moritaniTerror = placeTerror(game.moritaniTerror!,token,'arrakeen',1);
+ let ambassadorToken: string | undefined;
+ if (options.ambassador) {
+ game.ecazAmbassadors = createAmbassadors(() => 0);
+ const original = game.ecazAmbassadors.tokens.find(t => t.zone === 'supply')!;
+ ambassadorToken = original.id;
+ game.ecazAmbassadors = placeAmbassador(game.ecazAmbassadors,original.id,{turn:1,availableSpice:20,
+ destination:{id:'arrakeen',stronghold:true,inStorm:false,allowed:true}}).state;
+ game.order = options.ambassador === 'first' ? ['a','g','ec','m'] : ['a','g','m','ec'];
+ if (bg !== 'none') game.order.push('bg');
+ }
+ const karama = holdVictoryCard(game,'g','karama');
+ // Keep another real controlled contest so entry assertions exclude unrelated
+ // automatic Collection income after this one battle finishes.
+ const contest = location('shield_wall',territory('shield_wall').sectors[0]);
+ const donor = options.polarAlone ? 'hagga_basin:12' : 'polar_sink:0';
+ a.forces[donor]--; a.forces[contest] = 1;
+ guild.reserves--; guild.forces[contest] = 1;
+ caladanSneakInventory(game);
+ const source = caladanSneakReload(game), sourceActions: CaladanSneakStep[] = [];
+ game = actCaladanSneak(game,'a',{type:'chooseBattle',territory:'arrakeen',target:'g'},sourceActions);
+ game = settleCaladanSneak(game,sourceActions);
+ assert.ok(game.battle && !game.battle.preparation && !game.decision && !game.response);
+ const aLeader = caladanSneakPlayer(game,'a').leaders.reduce((best,l) => l.strength > best.strength ? l : best);
+ const gLeader = caladanSneakPlayer(game,'g').leaders.reduce((best,l) => l.strength < best.strength ? l : best);
+ game = actCaladanSneak(game,'a',{type:'battlePlan',leader:aLeader.id,dial:1,support:advanced ? 1 : 0},sourceActions);
+ game = actCaladanSneak(game,'g',{type:'battlePlan',leader:gLeader.id,dial:0,support:0},sourceActions);
+ game = settleCaladanSneak(game,sourceActions);
+ assert.equal(game.decision?.kind,'caladanReinforcement');
+ assert.equal(caladanSneakPlayer(game,'a').forces['arrakeen:10'],2);
+ const event = game.homeworldVictoryReinforcement!.event;
+ caladanSneakInventory(game);
+ return {game,source,sourceActions,actions:[...sourceActions],sourceCount:sourceActions.length,
+ disclosure:'Controlled Homeworld preview initializer and conserved later phase/positions/purses/cards/original token supply and placement; only recorded battle/arrival actions are native chronology, with no installed victory or child receipt.',
+ advanced,bg,ambassador:options.ambassador,token,ambassadorToken,karama,event,
+ territory:'arrakeen',destination:'arrakeen:10',
+ action:{type:'decision',event,destination:'arrakeen:10',amount:1} as Action,
+ offer:viewGame(game,'a').caladanReinforcement!};
+}
+
+export type CaladanSneakFixture = ReturnType;
+
+/** Reach the original revealed Sneak amount choice through actual decisions.
+ * If Ambassador was first, its actual decline leaves the original token in
+ * place so the later Moritani group can legitimately offer it again. */
+export function openCaladanSneak(f: CaladanSneakFixture): CaladanSneakFixture {
+ const actions = [...f.sourceActions];
+ let game = actCaladanSneak(caladanSneakReload(f.game),'a',f.action,actions);
+ assert.equal(game.pendingSpiritualAdvisor,undefined,'A direct Caladan addition creates no free-shipment source.');
+ if (game.pendingAmbassador) {
+ assert.equal(game.pendingAmbassador.entrant,'a');
+ game = actCaladanSneak(game,'ec',{type:'decision',event:game.pendingAmbassador.event,decline:true},actions);
+ }
+ assert.equal(game.pendingTerrorEntry?.stage,'offer');
+ assert.equal(game.pendingTerrorEntry?.victoryEvent,f.event);
+ game = actCaladanSneak(game,'m',{type:'decision',reveal:true},actions);
+ assert.equal(game.pendingTerrorEntry?.stage,'sneakAttack');
+ return {...f,game,actions};
+}
+
+export function commitCaladanSneak(f: CaladanSneakFixture, amount: 0|1 = 1): CaladanSneakFixture {
+ const opened = openCaladanSneak(f), actions = [...opened.actions];
+ const game = actCaladanSneak(opened.game,'m',{type:'decision',amount},actions);
+ caladanSneakInventory(game);
+ return {...opened,game,actions};
+}