Preserve Caladan Sneak Attack child arrivals

This commit is contained in:
Quintine committed 2026-10-27 15:05:53 +11:00
1 parent 2e4aba9630
commit f0ebdf9c87
16 files changed
+1065 -44

No files matched your search

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