From f0ebdf9c87607b5b85882e48d865cd5cde45b439 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:05:53 +1100 Subject: [PATCH] Preserve Caladan Sneak Attack child arrivals --- components/game-table.tsx | 5 +- docs/CURRENT_STATUS.md | 37 +++ docs/HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md | 53 +++++ docs/IMPLEMENTATION_STATUS.md | 19 ++ docs/RULE_DECISIONS.md | 12 + game/arrival-overlap.ts | 20 +- game/caladan-arrival.ts | 209 +++++++++++++++- game/engine.ts | 115 +++++++-- game/homeworld-arrival.ts | 47 ++-- game/homeworld-victory-return.ts | 7 +- game/reference.ts | 4 + game/spiritual-advisor.ts | 28 ++- game/terror-entry-receipt.ts | 9 +- tests/caladan-sneak-engine.test.ts | 223 ++++++++++++++++++ tests/caladan-sneak-source.test.ts | 166 +++++++++++++ tests/fixture-caladan-sneak.ts | 155 ++++++++++++ 16 files changed, 1065 insertions(+), 44 deletions(-) create mode 100644 tests/caladan-sneak-engine.test.ts create mode 100644 tests/caladan-sneak-source.test.ts create mode 100644 tests/fixture-caladan-sneak.ts diff --git a/components/game-table.tsx b/components/game-table.tsx index dd867a14..d8c69407 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -2619,6 +2619,7 @@ export function GameTable({ {' '}Allow the arrival or cancel Spiritual Advisors. Canceling prevents the whole declared group. {g.response.spiritualAdvisorSource&&<> Matching or an alone conversion may follow separately. The other faction’s shipment is already committed.} {g.response.spiritualAdvisorSource?.revivalEvent&&<> The revived group stays placed; this response does not cancel its revival.} + {g.response.spiritualAdvisorSource?.victoryEvent&&<> The Sneak Attack group stays placed; this response does not replay or refund its arrival.}

)} {g.response.kind === 'moritaniRetention' && @@ -3830,7 +3831,9 @@ export function GameTable({ ) : g.decision.kind === 'advisor' ? ( <>

- {g.decision.revivalEvent + {g.decision.caladanArrival + ? 'Moritani’s Sneak Attack group has arrived from off-planet. You may send' + : g.decision.revivalEvent ? 'Tleilaxu’s free-revived group has arrived from off-planet. You may send' : 'Another faction has shipped from off-planet. You may send'} {' '} diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 1377e4d7..ba157b15 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -20,6 +20,43 @@ open mode gates. Keep identified Basic-function gaps ahead of Advanced and wider interactions. Prototype labels and final acceptance gates remain. +**Caladan Sneak Attack child — 27 October. Prototyped / Partial.** +A positive actual Moritani reserve group now has its own arrival after both +owners of the original Caladan entry finish. BG’s free Spiritual opportunity, +Basic Polar fallback and Advanced accompaniment retain separate shipping, +matching/alone-conversion and prevention outcomes. An unconsumed Ambassador may +react again to the new group. Zero creates no group/source; no original debit, +victory grant or used token is replayed. Public packets use opaque UUIDs, with +sanitized actual trigger history kept engine-owned. The ordering composition +remains provisional. [Contract](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md#sneak-attack-child-arrival--27-october-2026). + +Frozen **14-file union116/116**, types/lint/build and twelve controlled runtime +journeys pass:56 GameTable SSR and8 actual inline advisor callbacks; recorded +battle/opening/further actions105/25/41. Actual battles and subsequent native +Caladan/Terror/quantity decisions generate the grants from explicitly staged +Homeworld audit setups, positions, purses, original cards/token supply/order and +one other contest holding Battle phase. No victory/child receipt is installed. +Six source tests separately construct signed controlled witnesses/ledger +surrogates; they prove helper contracts and legacy bytes, not native chronology. + +The runtime operates Advanced away/resident-advisor accompaniment, Basic Polar, +Polar allow/prevent conversion, shipping prevention, decline, both original +Ambassador orders and a combined second-owner/advisor/child-Ambassador journey. +Caladan/Moritani amount, hand-card and other reaction controls use native +packets; those stateful controls are not operated. No browser/hydration/mobile, +native acquisition, legal initial three-fighter stronghold Intrusion witness or +broader Nexus/Skills/Semuta/recovery/privacy/custody/deployed certification. + +Feedback respected the dispatcher’s cleared decision during actual transition, +normalized an absent optional test value and repaired two type declarations. +Failed v1/v2 stays preserved; v3 checks one frozen source plus a final three-doc +evidence overlay. Proof: +`/home/quintine/.local/share/dune/checkpoints/20261027-caladan-sneak-proof-v3/`; +journeys/HTML: adjacent `20261027-caladan-sneak-runtime/run-v2/`. +Existing server HTTP200, without restart or saved-game/database operation. +Full modes, remaining interactions and final acceptance remain open; full AI +refinement stays deferred. + **Caladan victory arrival — 27 October. Prototyped / Partial.** The original one-counter reserve addition now reaches a won HMS provisionally, resolves Advanced BG Intrusion first, then opens the original Ambassador/Terror diff --git a/docs/HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md b/docs/HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md index 6e1e2798..a9ce99c0 100644 --- a/docs/HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md +++ b/docs/HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md @@ -121,6 +121,59 @@ battle cleanup and persistence. The implemented flow and verification. No complete Homeworld, Advanced or expansion release is claimed. +## Sneak Attack child arrival — 27 October 2026 + +A positive Sneak Attack spends its actual off-planet reserves once. It is a +separate committed arrival after the Caladan addition, not a second victory +reinforcement. Its BG Intrusion/Spiritual opportunity and native entry reactions +follow the original Ambassador/Terror overlap, so the original second owner is +not overwritten. A declined Ambassador may react again to the new Moritani group; +a consumed token remains consumed. Zero places no group and creates no new +source. Existing native quantity, occupancy, payment and token effects remain. + +The source classification follows the existing Sneak off-planet shipment model +and the printed separate group. Serializing original overlap before that group's +reactions is an explicit provisional composition, consistent with the Revival +arrival flow; it is not a retrieved publisher sequencing example. The original +Caladan HMS/Terror rulings above remain provisional. Caladan itself still grants +no Spiritual Advisor shipment: only the actual positive Sneak group does. + +The new group's public packet uses an opaque UUID. A focused engine-only history +records the sanitized actual trigger, selected revealed Sneak token and committed +normal group. Private Terror candidate lists and raw entry signatures are not +copied into public source packets. Current reaction validation uses that original +receipt, not a new reserve quote or present inhabitants after accepted effects. +Older original/Revival signatures stay unchanged without the new parent. + +Advanced BG may accompany an eligible destination or select Polar Sink; Basic +retains its ordinary Polar fallback. Own retained fighters, matching and alone +conversion retain their separate legality/responses. Preventing free shipping +or a later conversion never refunds/replays the committed Sneak group. Standard +owned controls and minimal legal policies use the same projected choices. + +Frozen14-file116/116, types/lint/build and twelve controlled engine/GameTable +SSR programmes pass:56 table renders and8 actual inline advisor callbacks, +with105/25/41 recorded battle/opening/further actions. Native packets exercise +original Caladan/Terror/amount and cancellation controls; those stateful +controls are not operated. The source explicitly stages audit setup, positions, +purses, original cards/token supply/order and another contest. Actual native +battle/Caladan/Terror/quantity actions create every victory and child receipt. +The six controlled source/ledger-surrogate tests separately prove helper/history +contracts and old phase4/phase6 bytes, not native chronology. + +The runtime covers zero/positive, both original owners, a new child Ambassador, +Advanced away/resident-advisor accompaniment, Basic Polar, shipping prevention, +Polar conversion allowance/prevention and decline. The combined second-owner/BG/ +child journey preserves the original prompt. No legal initial three-fighting- +faction stronghold source is invented to claim Intrusion chronology. Source +feedback repaired the dispatcher-transition assumption, optional test absence +and two type declarations; failed v1/v2 remains preserved. +Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-caladan-sneak-proof-v3/`; +journeys/HTML: adjacent `20261027-caladan-sneak-runtime/run-v2/`. +No restart or saved-game/database operation. Browser/hydration/mobile, native +acquisition, deeper Nexus/Skills/Semuta combinations, native full games and final +assurance/deployed acceptance remain open. + ## Focused before-Face-Dance evidence — 26 October 2026 Frozen15-file189/189, types/lint/build and sixteen engine/GameTable SSR journeys diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index f8f9aef2..33d8cb85 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,24 @@ # Dune implementation status +## 27 October 2026 — Caladan Sneak Attack child arrival + +A positive actual Sneak group now receives a separate opaque source, after the +original Ambassador/Terror owners finish. Basic Polar/Advanced accompaniment, +independent shipping and conversion responses, a new eligible Ambassador and +minimal policies retain the one committed reserve group. Zero creates no source. +Engine-only sanitized history avoids publishing private Terror candidate lists. +The adopted source order remains provisional. + +Frozen14-file116/116, types/lint/build and twelve controlled runtime programmes +pass (56 table SSR,8 actual advisor callbacks;105/25/41 battle/opening/further +actions). Controlled setup and exact native-action boundaries are explicit; +six signed source witnesses/ledger surrogates are helper evidence. Failed v1/v2 +stays; v3 binds frozen source plus final three-document overlay. Existing server +HTTP200 without restart/saved-game/database operation. [Current checkpoint](CURRENT_STATUS.md#current-checkpoint-and-work), +[contract/proof and limits](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md#sneak-attack-child-arrival--27-october-2026). +No native acquisition, full-mode, broader combination, browser/deployed or final +assurance certification; full AI refinement remains deferred. + ## 27 October 2026 — Caladan victory arrival One actual reserve counter now reaches a won HMS provisionally and resolves diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 18a68b9a..94f7c8c0 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,17 @@ # Rule decision index +## Caladan-triggered Sneak Attack child — 27 October 2026 + +A positive actual Sneak group receives its own off-planet arrival source, after +both owners of the original Caladan entry finish. Under standing recommended- +option authority, this ordering is provisional composition of the existing +Sneak/Spiritual entry model, not a specific publisher example. Zero has no new +source. An unconsumed Ambassador may react to that separate group, while used +physical tokens and the original single reserve addition remain spent. Basic +Polar and Advanced accompaniment retain native permissions and independent +Karama/conversion responses. Public source packets use opaque events; sanitized +trigger history stays engine-owned. [Contract](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md#sneak-attack-child-arrival--27-october-2026). + ## Caladan victory placement and entry — 27 October 2026 Compose the original Caladan high face's one-counter addition with Advanced diff --git a/game/arrival-overlap.ts b/game/arrival-overlap.ts index 19a95274..57033f00 100644 --- a/game/arrival-overlap.ts +++ b/game/arrival-overlap.ts @@ -1,5 +1,6 @@ import type { Game } from './engine'; -import { caladanOwnsTerrorEntry } from './caladan-arrival'; +import { caladanOwnsTerrorEntry, validateCaladanChildArrival, caladanEntryParentTuple, + validCaladanEntryParent, type CaladanEntryParent } from './caladan-arrival'; import { currentHomeworldRevivalArrival } from './homeworld-revival-return'; import { validateRevivalArrivalSource, revivalOwnsTerrorEntry, validRevivalEntryParent, revivalEntryParentTuple, type RevivalEntryParent } from './revival-terror-entry'; @@ -14,6 +15,10 @@ function sameRevivalSource(left: RevivalEntryParent | undefined, right: RevivalE return validRevivalEntryParent(left) && validRevivalEntryParent(right) && JSON.stringify(revivalEntryParentTuple(left)) === JSON.stringify(revivalEntryParentTuple(right)); } +function sameCaladanSource(left: CaladanEntryParent | undefined, right: CaladanEntryParent | undefined) { + return validCaladanEntryParent(left) && validCaladanEntryParent(right) && + JSON.stringify(caladanEntryParentTuple(left)) === JSON.stringify(caladanEntryParentTuple(right)); +} /** The immutable trigger, not later owner choices or child stages. */ export function arrivalOverlapSignature(record: Overlap): string { @@ -22,7 +27,8 @@ export function arrivalOverlapSignature(record: Overlap): string { record.resume, record.ecaz, record.moritani, record.first, record.ambassadorToken, record.terrorTokens, ...(record.revivalArrival === undefined ? [] : [revivalEntryParentTuple(record.revivalArrival)]), - ...(record.victoryEvent === undefined ? [] : [['caladanVictory', record.victoryEvent]])]); + ...(record.victoryEvent === undefined ? [] : [['caladanVictory', record.victoryEvent]]), + ...(record.caladanArrival === undefined ? [] : [caladanEntryParentTuple(record.caladanArrival)])]); } /** A fresh discard belongs to the active owner of an ordinary or bound revived entry. @@ -33,7 +39,10 @@ function ordinaryDiscardOverlap(g: DiscardContext, owner: Overlap['first']): Ove overlap.turn !== g.turn || overlap.phase !== g.phase || overlap.resume !== 'none' || !['first', 'second'].includes(overlap.stage)) return null; - if (overlap.revivalArrival !== undefined) { + if (overlap.caladanArrival !== undefined) { + try { validateCaladanChildArrival(g,overlap.caladanArrival,overlap); } + catch { return null; } + } else if (overlap.revivalArrival !== undefined) { try { validateRevivalArrivalSource(g, overlap.revivalArrival, overlap); } catch { return null; } @@ -54,12 +63,16 @@ function ordinaryDiscardOverlap(g: DiscardContext, owner: Overlap['first']): Ove export function overlapOwnsAmbassadorDiscard(g: DiscardContext, entry: AmbassadorEntry): boolean { const overlap = ordinaryDiscardOverlap({ ...g, pendingAmbassador: entry }, 'ambassador'); const arrival = currentHomeworldRevivalArrival(g.homeworldRevivalReturn)?.ambassadors?.find(record => record.event === entry.event); + const caladan = g.homeworldVictoryReinforcement?.ambassadors?.find(record => record.event === entry.event); return !!overlap && entry.stage === 'cards' && entry.resume === 'none' && entry.event === overlap.ambassadorEvent && entry.owner === overlap.ecaz && entry.token === overlap.ambassadorToken && entry.entrant === overlap.entrant && entry.territory === overlap.territory && entry.sector === overlap.sector && entry.arrivalCause === overlap.cause && entry.turn === overlap.turn && entry.phase === overlap.phase && (overlap.victoryEvent === undefined || entry.victoryEvent === overlap.victoryEvent) && + (overlap.caladanArrival === undefined || entry.victoryEvent === overlap.caladanArrival.event && + !!caladan?.terror && sameCaladanSource(overlap.caladanArrival,{event:entry.victoryEvent, + terror:caladan.terror,...(caladan.spiritual ? {spiritual:caladan.spiritual} : {})})) && (overlap.revivalArrival === undefined || entry.revivalEvent === overlap.revivalArrival.event && !!arrival && sameRevivalSource(overlap.revivalArrival, { event: overlap.revivalArrival.event, ...(arrival.spiritual === undefined ? {} : { spiritual: arrival.spiritual }), @@ -74,6 +87,7 @@ export function overlapOwnsTerrorDiscard(g: DiscardContext, entry: TerrorEntry, entry.amount === overlap.amount && entry.elite === overlap.elite && entry.cause === overlap.cause && entry.turn === overlap.turn && entry.phase === overlap.phase && (overlap.victoryEvent === undefined || entry.victoryEvent === overlap.victoryEvent) && + (overlap.caladanArrival === undefined || sameCaladanSource(overlap.caladanArrival,entry.caladanArrival)) && (overlap.revivalArrival === undefined || sameRevivalSource(overlap.revivalArrival, entry.revivalArrival) && revivalOwnsTerrorEntry(g, entry)); } diff --git a/game/caladan-arrival.ts b/game/caladan-arrival.ts index b42e1032..cc17d9e2 100644 --- a/game/caladan-arrival.ts +++ b/game/caladan-arrival.ts @@ -1,7 +1,10 @@ import type { Game } from './engine'; -import { location, validLocation } from './board'; +import { location, splitLocation, validLocation } from './board'; import { HomeworldCustodyError } from './homeworld-custody'; import { validateHomeworldVictoryReturn } from './homeworld-victory-return'; +import type { FactionId } from './catalog'; +import type { SpiritualAdvisorSource } from './spiritual-advisor'; +import type { RevivalTerrorPacket } from './revival-terror-entry'; export type CaladanArrivalContext = Pick; @@ -35,3 +38,207 @@ export function caladanOwnsTerrorEntry(g: CaladanArrivalContext, entry: CaladanA throw error; } } + +export type CaladanEntryParent = { + event: string; + terror: RevivalTerrorPacket; + spiritual?: SpiritualAdvisorSource; +}; +export type CaladanSneakTrigger = Omit & { + victoryEvent?: string; + caladanArrival?: CaladanEntryParent; +}; +/** Engine-only consistency history. No raw Terror signature, candidate list, + * current force snapshot or private deck is part of this receipt. */ +export type CaladanSneakReceipt = { + version: 1; + victoryEvent: string; + turn: number; + phase: 6; + token: string; + trigger: CaladanSneakTrigger; + packet: RevivalTerrorPacket; + signature: string; +}; +export type CaladanTerrorHistoryContext = { + turn: number; + phase: number; + players: readonly { id: string; faction: FactionId }[]; + victoryPlayer: string; + terrorArrivals: readonly CaladanSneakReceipt[]; +}; +type CaladanTerrorEntry = NonNullable & { caladanArrival?: CaladanEntryParent }; +const record = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); +const text = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0; +const count = (value: unknown, maximum = 20): value is number => + Number.isSafeInteger(value) && (value as number) >= 0 && (value as number) <= maximum; +function exact(value: unknown, names: readonly string[]): value is Record { + return record(value) && Object.keys(value).length === names.length && names.every(key => Object.hasOwn(value, key)); +} +function fail(): never { + throw new HomeworldCustodyError('The Caladan Sneak Attack lost its original committed arrival source.'); +} +const uuid = (value: unknown): value is string => typeof value === 'string' && + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); +function validPacket(value: unknown): value is RevivalTerrorPacket { + return exact(value, ['event', 'owner', 'territory', 'sector', 'amount', 'elite']) && uuid(value.event) && + text(value.owner) && text(value.territory) && count(value.sector, 18) && + validLocation(value.territory, value.sector) && count(value.amount, 5) && value.amount > 0 && value.elite === 0; +} +function validSpiritual(value: unknown, event: string): value is SpiritualAdvisorSource { + return exact(value, ['event', 'owner', 'turn', 'phase', 'territory', 'sector', 'amount', 'victoryEvent']) && + text(value.event) && text(value.owner) && Number.isSafeInteger(value.turn) && (value.turn as number) > 0 && + value.phase === 6 && text(value.territory) && count(value.sector, 18) && + validLocation(value.territory, value.sector) && (value.amount === 1 || value.amount === 2) && value.victoryEvent === event; +} +export function validCaladanEntryParent(value: unknown): value is CaladanEntryParent { + return record(value) && exact(value, Object.hasOwn(value, 'spiritual') ? ['event', 'terror', 'spiritual'] : ['event', 'terror']) && + text(value.event) && validPacket(value.terror) && + (!Object.hasOwn(value, 'spiritual') || validSpiritual(value.spiritual, value.event)); +} +function packetTuple(packet: RevivalTerrorPacket) { + return [packet.event, packet.owner, packet.territory, packet.sector, packet.amount, packet.elite]; +} +export function caladanEntryParentTuple(parent: CaladanEntryParent): unknown[] { + if (!validCaladanEntryParent(parent)) fail(); + const source = parent.spiritual; + return ['caladanArrival', parent.event, source ? [source.event, source.owner, source.turn, source.phase, + source.territory, source.sector, source.amount, source.victoryEvent] : null, packetTuple(parent.terror)]; +} +function triggerTuple(trigger: CaladanSneakTrigger) { + return [trigger.entrant, trigger.territory, trigger.sector, trigger.amount, trigger.elite, trigger.cause, + trigger.victoryEvent ?? null, trigger.caladanArrival ? caladanEntryParentTuple(trigger.caladanArrival) : null]; +} +function receiptSignature(receipt: CaladanSneakReceipt): string { + return JSON.stringify(['caladanSneak', receipt.version, receipt.victoryEvent, receipt.turn, receipt.phase, + receipt.token, triggerTuple(receipt.trigger), packetTuple(receipt.packet)]); +} +function validTrigger(trigger: unknown): trigger is CaladanSneakTrigger { + if (!record(trigger)) return false; + const root = Object.hasOwn(trigger, 'victoryEvent'), child = Object.hasOwn(trigger, 'caladanArrival'); + return root !== child && exact(trigger, ['entrant', 'territory', 'sector', 'amount', 'elite', 'cause', + root ? 'victoryEvent' : 'caladanArrival']) && text(trigger.entrant) && text(trigger.territory) && + count(trigger.sector, 18) && validLocation(trigger.territory, trigger.sector) && + count(trigger.amount) && trigger.amount > 0 && count(trigger.elite, trigger.amount) && text(trigger.cause) && + (root ? text(trigger.victoryEvent) : validCaladanEntryParent(trigger.caladanArrival)); +} +function childMatches(context: CaladanTerrorHistoryContext, parent: CaladanEntryParent, details: CaladanArrivalDetails) { + if (parent.spiritual) { + const source = parent.spiritual; + return context.players.filter(player => player.id === source.owner && player.faction === 'beneGesserit').length === 1 && + source.owner !== context.victoryPlayer && source.turn === context.turn && source.phase === context.phase && + (source.territory === parent.terror.territory || source.territory === 'polar_sink') && + details.entrant === source.owner && details.cause === 'advisor' && details.territory === source.territory && + details.sector === source.sector && details.amount === source.amount && details.elite === 0; + } + const packet = parent.terror; + return details.entrant === packet.owner && details.cause === 'shipment' && details.territory === packet.territory && + details.sector === packet.sector && details.amount === packet.amount && details.elite === packet.elite; +} + +function validatePacketHistory(context: CaladanTerrorHistoryContext, packet: RevivalTerrorPacket, + destination: string, victoryEvent: string, seen: Set): void { + if (!validPacket(packet) || seen.size > 16 || seen.has(packet.event) || context.phase !== 6 || + !Number.isSafeInteger(context.turn) || context.turn < 1 || !Array.isArray(context.terrorArrivals) || + context.players.filter(player => player.id === context.victoryPlayer && player.faction === 'atreides').length !== 1 || + context.players.filter(player => player.id === packet.owner && player.faction === 'moritani').length !== 1) fail(); + const point = splitLocation(destination); + if (!text(victoryEvent) || destination !== location(point.territory, point.sector) || + !validLocation(point.territory, point.sector)) fail(); + const matches = context.terrorArrivals.filter(receipt => record(receipt) && record(receipt.packet) && receipt.packet.event === packet.event); + if (matches.length !== 1) fail(); + const receipt = matches[0]; + if (!exact(receipt, ['version', 'victoryEvent', 'turn', 'phase', 'token', 'trigger', 'packet', 'signature']) || + receipt.version !== 1 || receipt.victoryEvent !== victoryEvent || receipt.turn !== context.turn || receipt.phase !== 6 || + !text(receipt.token) || !validTrigger(receipt.trigger) || !validPacket(receipt.packet) || + JSON.stringify(packetTuple(receipt.packet)) !== JSON.stringify(packetTuple(packet)) || + receipt.signature !== receiptSignature(receipt as CaladanSneakReceipt) || receipt.trigger.territory !== packet.territory || + receipt.trigger.sector !== packet.sector) fail(); + seen.add(packet.event); + const trigger = receipt.trigger; + if (trigger.victoryEvent !== undefined) { + if (trigger.victoryEvent !== victoryEvent || trigger.entrant !== context.victoryPlayer || + trigger.territory !== point.territory || trigger.sector !== point.sector || + trigger.amount !== 1 || trigger.elite !== 0 || trigger.cause !== 'caladanReinforcement') fail(); + } else { + const parent = trigger.caladanArrival!; + if (parent.event !== victoryEvent || !childMatches(context, parent, trigger)) fail(); + validatePacketHistory(context, parent.terror, destination, victoryEvent, seen); + } +} + +/** Resolve an opaque public packet through original sanitized engine history; + * no current reserves, inhabitants or removed-token custody are re-read. */ +export function validateCaladanTerrorPacket(context: CaladanTerrorHistoryContext, packet: RevivalTerrorPacket, + destination: string, victoryEvent: string): void { + validatePacketHistory(context, packet, destination, victoryEvent, new Set()); +} + +export function validateCaladanSneakHistory(g: Pick): void { + const frame = g.homeworldVictoryReinforcement; + if (frame?.terrorArrivals === undefined) return; + if (!Array.isArray(frame.terrorArrivals) || frame.turn > g.turn || frame.phase !== 6 || + !['arrival', 'complete'].includes(frame.stage) || !text(frame.destination) || frame.destination === 'decline') fail(); + const seen = new Set(); + const context: CaladanTerrorHistoryContext = { turn: frame.turn, phase: frame.phase, players: g.players, + victoryPlayer: frame.player, terrorArrivals: frame.terrorArrivals }; + for (const receipt of frame.terrorArrivals) { + if (!record(receipt) || !validPacket(receipt.packet) || seen.has(receipt.packet.event)) fail(); + seen.add(receipt.packet.event); + validateCaladanTerrorPacket(context, receipt.packet, frame.destination, frame.event); + } +} + +/** A new entry belongs to the committed Sneak packet, optionally followed by + * its own actual Spiritual packet; the original Caladan force is not reused. */ +export function validateCaladanChildArrival(g: CaladanArrivalContext, parent: CaladanEntryParent, + details: CaladanArrivalDetails): void { + validateHomeworldVictoryReturn(g); + const frame = g.homeworldVictoryReinforcement; + if (!validCaladanEntryParent(parent) || g.status !== 'playing' || g.phase !== 6 || !frame || frame.stage !== 'arrival' || + frame.turn !== g.turn || parent.event !== frame.event || !text(frame.destination) || + details.victoryEvent !== undefined || !count(details.amount) || details.amount < 1 || !count(details.elite, details.amount)) fail(); + const context: CaladanTerrorHistoryContext = { turn: frame.turn, phase: frame.phase, players: g.players, + victoryPlayer: frame.player, terrorArrivals: frame.terrorArrivals ?? [] }; + validateCaladanTerrorPacket(context, parent.terror, frame.destination, parent.event); + if (!childMatches(context, parent, details)) fail(); +} + +function sanitizedTrigger(entry: CaladanTerrorEntry): CaladanSneakTrigger { + const trigger: CaladanSneakTrigger = { entrant: entry.entrant, territory: entry.territory, sector: entry.sector, + amount: entry.amount, elite: entry.elite, cause: entry.cause, + ...(entry.victoryEvent === undefined ? {} : { victoryEvent: entry.victoryEvent }), + ...(entry.caladanArrival === undefined ? {} : { caladanArrival: structuredClone(entry.caladanArrival) }) }; + if (!validTrigger(trigger)) fail(); + return trigger; +} + +/** Quote before native debit; the engine appends the receipt only after the + * actual positive transfer, then queues this packet behind the original overlap. */ +export function createCaladanSneakReceipt(g: Game, entry: CaladanTerrorEntry, owner: string, amount: number, + eventUUID: string): { packet: RevivalTerrorPacket; receipt: CaladanSneakReceipt } { + const pending = g.pendingTerrorEntry as CaladanTerrorEntry | null | undefined; + const token = g.moritaniTerror?.tokens.find(candidate => candidate.id === entry.token); + if (g.status !== 'playing' || g.phase !== 6 || !pending || pending.stage !== 'sneakAttack' || + entry.stage !== 'sneakAttack' || pending.token !== entry.token || entry.turn !== g.turn || entry.phase !== 6 || + pending.turn !== g.turn || pending.phase !== 6 || entry.revivalArrival !== undefined || + (g.decision && (g.decision.kind !== 'moritaniTerror' || g.decision.player !== owner)) || + g.players.filter(player => player.id === owner && player.faction === 'moritani').length !== 1 || + !token || token.kind !== 'sneakAttack' || token.status !== 'removed' || token.location !== null || + !count(amount, 5) || amount < 1 || !uuid(eventUUID)) fail(); + const trigger = sanitizedTrigger(entry); + if (JSON.stringify(triggerTuple(trigger)) !== JSON.stringify(triggerTuple(sanitizedTrigger(pending)))) fail(); + if (entry.victoryEvent !== undefined) validateCaladanArrivalSource(g, entry); + else if (entry.caladanArrival) validateCaladanChildArrival(g, entry.caladanArrival, entry); + else fail(); + const frame = g.homeworldVictoryReinforcement!; + validateCaladanSneakHistory(g); + if (frame.terrorArrivals?.some(receipt => receipt.packet.event === eventUUID)) fail(); + const packet: RevivalTerrorPacket = { event: eventUUID, owner, territory: entry.territory, + sector: entry.sector, amount, elite: 0 }; + const receipt: CaladanSneakReceipt = { version: 1, victoryEvent: frame.event, turn: g.turn, phase: 6, + token: token.id, trigger, packet: { ...packet }, signature: '' }; + receipt.signature = receiptSignature(receipt); + return { packet, receipt }; +} diff --git a/game/engine.ts b/game/engine.ts index d147c101..f818228a 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -191,7 +191,8 @@ import { ambassadorPhaseAllowed } from './ambassador-phase'; import { makeHomeworldVictoryReturn, validateHomeworldVictoryReturn, homeworldVictoryOfferSignature, homeworldVictoryObligationSignature, type HomeworldVictoryReturn, type HomeworldVictoryObligation } from './homeworld-victory-return'; import { quoteHomeworldVictoryReinforcement, quoteHomeworldVictoryReinforcementDestination } from './homeworld-victory-reinforcement'; -import { validateCaladanArrivalSource } from './caladan-arrival'; +import { validateCaladanArrivalSource, validateCaladanChildArrival, createCaladanSneakReceipt, + validateCaladanSneakHistory, type CaladanEntryParent } from './caladan-arrival'; import { homeworldArrivalSignature, appendHomeworldArrivalAmbassador, completeHomeworldArrivalAmbassador, validateHomeworldArrival } from './homeworld-arrival'; import { quoteHomeworldRevivalDeployment, @@ -1101,6 +1102,7 @@ export type Decision = | { kind: 'nexusEmperorAmbassadorPurchase'; player: string; event: string } | { kind: 'advisor'; + caladanArrival?: CaladanEntryParent; player: string; shipment: string; destination?: string; @@ -1110,6 +1112,7 @@ export type Decision = } | { kind: 'intrusion'; + caladanArrival?: CaladanEntryParent; victoryEvent?: string; player: string; territory: string; @@ -1118,7 +1121,7 @@ export type Decision = ambassadorEvent?: string; revivalEvent?: string; revivalTerror?: RevivalEntryParent['terror']; - followup?: { shipment: string; destination: string; revivalEvent?: string; revivalTerror?: RevivalEntryParent['terror'] }; + followup?: { shipment: string; destination: string; caladanArrival?: CaladanEntryParent; revivalEvent?: string; revivalTerror?: RevivalEntryParent['terror'] }; } | { kind: 'advisorBattle'; player: string; territories: string[] } | { @@ -1195,11 +1198,12 @@ export type ResponseWindow = { advisorGatherMove?: { turn: number; move: number; order: MovementOrder }; discoveryEntry?: DiscoveryEntryArrivalChild; advisorAmbassadorEvent?: string; + advisorCaladanArrival?: CaladanEntryParent; advisorVictoryEvent?: string; advisorRevivalEvent?: string; advisorRevivalTerror?: RevivalEntryParent['terror']; advisorRemaining?: string[]; - advisorFollowup?: { shipment: string; destination: string; revivalEvent?: string; revivalTerror?: RevivalEntryParent['terror'] }; + advisorFollowup?: { shipment: string; destination: string; caladanArrival?: CaladanEntryParent; revivalEvent?: string; revivalTerror?: RevivalEntryParent['terror'] }; amount?: number; elite?: number; recipient?: string; @@ -2050,6 +2054,7 @@ export type Game = { dukeAcquisitionTurn?: number; pendingTerrorEntry?: { victoryEvent?: string; + caladanArrival?: CaladanEntryParent; revivalArrival?: RevivalEntryParent; /** Original public arrival, absent only in legacy saves. */ entrySignature?: string; @@ -2107,13 +2112,16 @@ export type Game = { resume: 'none' | 'wormRide'; turn: number; phase: number; + caladanArrival?: CaladanEntryParent; revivalArrival?: RevivalEntryParent; /** A committed revival Sneak group waits for the original overlap before BG reactions. */ revivalSneakAdvisor?: true; + caladanSneakAdvisor?: true; }[] | null; /** One committed entry with two independently optional expansion reactions. */ pendingArrivalOverlap?: { victoryEvent?: string; + caladanArrival?: CaladanEntryParent; revivalArrival?: RevivalEntryParent; event: string; signature: string; @@ -21767,6 +21775,7 @@ function arrivalOverlapIntegrity(g: Game) { (continuation?.kind === 'ambassador' ? continuation.entry : null); const terror = g.pendingTerrorEntry ?? (continuation?.kind === 'terrorDiscard' || continuation?.kind === 'terrorAtomics' ? continuation.entry : null); + if (record.caladanArrival) homeworldRule(() => validateCaladanChildArrival(g,record.caladanArrival!,record)); if (record.revivalArrival) homeworldRule(() => validateRevivalArrivalSource(g, record.revivalArrival!, record)); if (record.victoryEvent !== undefined || record.cause === 'caladanReinforcement') @@ -21812,6 +21821,7 @@ function arrivalOverlapIntegrity(g: Game) { terror.turn === record.turn && terror.phase === record.phase && terror.cause === record.cause && JSON.stringify(terror.revivalArrival) === JSON.stringify(record.revivalArrival) && + JSON.stringify(terror.caladanArrival) === JSON.stringify(record.caladanArrival) && record.terrorTokens.includes(terror.token)), 'The saved Ambassador/Terror overlap lost its original ordered arrival.'); } @@ -21820,6 +21830,7 @@ function openAmbassadorEntry( sector: number, resume: 'none' | 'wormRide', arrivalCause?: NonNullable['cause'], revivalArrival?: RevivalEntryParent, + caladanArrival?: CaladanEntryParent, ): boolean { g.pendingAmbassador = { ...(arrivalCause ? { arrivalCause } : {}), @@ -21847,6 +21858,13 @@ function openAmbassadorEntry( g.pendingAmbassador.revivalEvent = frame.event; homeworldRule(() => appendHomeworldArrivalAmbassador(frame, g.pendingAmbassador!, undefined, revivalArrival.spiritual, revivalArrival.terror)); + } else if (caladanArrival) { + const frame = g.homeworldVictoryReinforcement!; + homeworldRule(() => validateCaladanChildArrival(g, caladanArrival, {entrant:entrant.id, + territory:to,sector,amount:caladanArrival.spiritual?.amount ?? caladanArrival.terror.amount,elite:0, + cause:arrivalCause ?? ''})); + g.pendingAmbassador.victoryEvent = frame.event; + appendHomeworldArrivalAmbassador(frame,g.pendingAmbassador,undefined,caladanArrival.spiritual,caladanArrival.terror); } else if (arrivalCause === 'caladanReinforcement') { const frame = g.homeworldVictoryReinforcement!; homeworldRule(() => validateCaladanArrivalSource(g, {victoryEvent:frame?.event, entrant:entrant.id, @@ -21875,10 +21893,14 @@ function openTerritoryEntry( cause: NonNullable['cause'], resume: 'none' | 'wormRide' = 'none', revivalArrival?: RevivalEntryParent, + caladanArrival?: CaladanEntryParent, ) { if (revivalArrival) homeworldRule(() => validateRevivalArrivalSource(g, revivalArrival, {entrant:entrant.id,territory:to,sector,amount,elite,cause})); + if (caladanArrival) + homeworldRule(() => validateCaladanChildArrival(g, caladanArrival, + {entrant:entrant.id,territory:to,sector,amount,elite,cause})); const victoryEvent = cause === 'caladanReinforcement' ? g.homeworldVictoryReinforcement?.event : undefined; if (cause === 'caladanReinforcement') homeworldRule(() => validateCaladanArrivalSource(g, {victoryEvent, entrant:entrant.id, territory:to, sector, amount, elite, cause})); @@ -21891,6 +21913,7 @@ function openTerritoryEntry( turn: g.turn, phase: g.phase, ...(victoryEvent === undefined ? {} : {victoryEvent}), ...(revivalArrival ? { revivalArrival } : {}), + ...(caladanArrival ? {caladanArrival} : {}), }); return true; } @@ -21921,6 +21944,7 @@ function openTerritoryEntry( resume, undefined, revivalArrival, + caladanArrival, ); const moritani = byFaction(g, 'moritani'); const terrorTokens = moritani && @@ -21932,7 +21956,7 @@ function openTerritoryEntry( requireRule(!g.response && !g.decision && !g.pendingTerrorEntry && !g.pendingAmbassador, 'Finish the other arrival reaction before opening this entry.'); if (!terrorTokens.length) - return openAmbassadorEntry(g, owner, token, entrant, to, sector, resume, cause, revivalArrival); + return openAmbassadorEntry(g, owner, token, entrant, to, sector, resume, cause, revivalArrival, caladanArrival); requireRule(arrivalOverlapModeSupported(g, revivalArrival) && !g.pendingArrivalOverlap && moritani && g.order.includes(owner.id) && g.order.includes(moritani.id), 'Ambassador and Terror overlap still needs a supported classic Ecaz/Moritani table.'); @@ -21945,13 +21969,14 @@ function openTerritoryEntry( ambassadorToken: token.id, terrorTokens: terrorTokens.map(t => t.id), ...(victoryEvent === undefined ? {} : {victoryEvent}), ...(revivalArrival ? {revivalArrival:structuredClone(revivalArrival)} : {}), + ...(caladanArrival ? {caladanArrival:structuredClone(caladanArrival)} : {}), }; overlap.signature = arrivalOverlapSignature(overlap); g.pendingArrivalOverlap = overlap; log(g, `${owner.name} and ${moritani.name} both have optional reactions to ${entrant.name}'s entry in ${territory(to).name}; ${first === 'ambassador' ? owner.name : moritani.name} acts first in storm order.`); if (first === 'terror') - return openTerrorEntry(g, entrant, to, sector, amount, elite, cause, 'none', undefined, revivalArrival); - openAmbassadorEntry(g, owner, token, entrant, to, sector, 'none', cause, revivalArrival); + return openTerrorEntry(g, entrant, to, sector, amount, elite, cause, 'none', undefined, revivalArrival, caladanArrival); + openAmbassadorEntry(g, owner, token, entrant, to, sector, 'none', cause, revivalArrival, caladanArrival); overlap.ambassadorEvent = g.pendingAmbassador!.event; return true; } @@ -21990,7 +22015,7 @@ function finishAmbassador(g: Game, deferResume = false) { overlap.stage = 'second'; requireRule(openTerrorEntry(g, getPlayer(g, overlap.entrant), overlap.territory, overlap.sector, overlap.amount, overlap.elite, - overlap.cause, 'none', undefined, overlap.revivalArrival), + overlap.cause, 'none', undefined, overlap.revivalArrival, overlap.caladanArrival), 'The originally triggered Terror token is no longer in the stronghold.'); return; } @@ -24230,6 +24255,7 @@ function terrorEntryIntegrity(g: Game) { if (!entry) continue; homeworldRule(() => validateTerrorEntrySignature(entry)); if (entry.revivalArrival) homeworldRule(() => validateRevivalTerrorEntry(g, entry)); + if (entry.caladanArrival) homeworldRule(() => validateCaladanChildArrival(g,entry.caladanArrival!,entry)); if (entry.victoryEvent !== undefined || entry.cause === 'caladanReinforcement') homeworldRule(() => validateCaladanArrivalSource(g, entry)); if (entry.entrySignature === undefined) continue; @@ -24272,10 +24298,14 @@ function openTerrorEntry( resume: 'none' | 'wormRide' | 'ambassador' = 'none', ambassadorEvent?: string, revivalArrival?: RevivalEntryParent, + caladanArrival?: CaladanEntryParent, ) { if (revivalArrival) homeworldRule(() => validateRevivalArrivalSource(g, revivalArrival, {entrant:entrant.id,territory:to,sector,amount,elite,cause})); + if (caladanArrival) + homeworldRule(() => validateCaladanChildArrival(g, caladanArrival, + {entrant:entrant.id,territory:to,sector,amount,elite,cause})); const moritani = byFaction(g, 'moritani'); const overlap = g.pendingArrivalOverlap; const originallyTriggered = !!overlap && @@ -24312,6 +24342,7 @@ function openTerrorEntry( ...(ambassadorEvent ? { ambassadorEvent } : {}), ...(cause === 'caladanReinforcement' ? {victoryEvent:g.homeworldVictoryReinforcement?.event} : {}), ...(revivalArrival ? {revivalArrival:structuredClone(revivalArrival)} : {}), + ...(caladanArrival ? {caladanArrival:structuredClone(caladanArrival)} : {}), }; if (!TERROR_STRONGHOLDS.includes(to)) { const proofs = tokens.map(token => { @@ -24349,7 +24380,7 @@ function finishTerrorEntry(g: Game) { t.location === overlap.territory); requireRule(token, 'The originally triggered Ambassador token is no longer present.'); openAmbassadorEntry(g, getPlayer(g, overlap.ecaz), token, - getPlayer(g, overlap.entrant), overlap.territory, overlap.sector, 'none', overlap.cause, overlap.revivalArrival); + getPlayer(g, overlap.entrant), overlap.territory, overlap.sector, 'none', overlap.cause, overlap.revivalArrival, overlap.caladanArrival); overlap.ambassadorEvent = g.pendingAmbassador!.event; return; } @@ -24650,6 +24681,8 @@ function decideTerror(g: Game, p: Player, action: Action) { action.sector === undefined || action.sector === entry.sector, 'Sneak Attack must enter the recorded sector.', ); + const caladan = amount > 0 && (entry.victoryEvent || entry.caladanArrival) + ? homeworldRule(() => createCaladanSneakReceipt(g,entry,p.id,amount,crypto.randomUUID())) : undefined; if (amount > 0) { requireRule( !options.blocked, @@ -24672,7 +24705,14 @@ function decideTerror(g: Game, p: Player, action: Action) { const revivalArrival: RevivalEntryParent | undefined = amount > 0 && entry.revivalArrival ? {event:entry.revivalArrival.event,terror:{event:entry.entrySignature!,owner:p.id, territory:entry.territory,sector:entry.sector,amount,elite:0}} : undefined; + if (caladan) (g.homeworldVictoryReinforcement!.terrorArrivals ??= []).push(caladan.receipt); finishTerrorEntry(g); + if (caladan) { + (g.pendingArrivalReaction ??= []).push({entrant:p.id,territory:entry.territory,sector:entry.sector, + amount,elite:0,cause:'shipment',resume:'none',turn:g.turn,phase:g.phase, + caladanArrival:{event:g.homeworldVictoryReinforcement!.event,terror:caladan.packet},caladanSneakAdvisor:true}); + return; + } // Keep the original two-owner opportunity intact before this new group's // reactions; never overwrite an Ambassador opened by finishing Terror. if (revivalArrival) { @@ -24815,6 +24855,9 @@ function spiritualParentSignature(g:Game,decision:Extract,action:Action){ + if(decision.caladanArrival) homeworldRule(() => validateCaladanChildArrival(g,decision.caladanArrival!, + {entrant:decision.shipment,territory:decision.caladanArrival!.terror.territory, + sector:decision.caladanArrival!.terror.sector,amount:decision.caladanArrival!.terror.amount,elite:0,cause:'shipment'})); const bg=getPlayer(g,decision.player); let order:{player:string;territory:string;sector:number;amount:number;elite:0;advisors:boolean}; if(decision.ambassadorEvent){ @@ -24846,7 +24889,7 @@ function quoteSpiritualOrder(g:Game,decision:Extract, } function spiritualAdvisorChoices(g:Game,id:string){ const decision=g.decision; - if(decision?.kind!=='advisor'||decision.player!==id||(!g.advanced&&!decision.revivalEvent))return null; + if(decision?.kind!=='advisor'||decision.player!==id||(!g.advanced&&!decision.revivalEvent&&!decision.caladanArrival))return null; const destination=decision.destination?splitLocation(decision.destination).territory:null; const choices=[...(g.advanced&&destination?gameTerritories(g).find(t=>t.id===destination)?.sectors.map(sector=> ({accompany:true,territory:destination,sector,amount:1}))??[]:[]), @@ -24868,7 +24911,7 @@ function spiritualAdvisorChoices(g:Game,id:string){ } function spiritualFrameBody(g:Game){ const frame=g.pendingSpiritualAdvisor; - requireRule(frame&&(g.advanced||frame.phase===4&&!!frame.decision.revivalEvent)&&g.status==='playing'&&frame.turn===g.turn&&frame.phase===g.phase&& + requireRule(frame&&(g.advanced||frame.phase===4&&!!frame.decision.revivalEvent||!!frame.decision.caladanArrival)&&g.status==='playing'&&frame.turn===g.turn&&frame.phase===g.phase&& getPlayer(g,frame.decision.player).faction==='beneGesserit'&& frame.signature===JSON.stringify([frame.event,spiritualParentSignature(g,frame.decision)]), 'Spiritual Advisors lost the original committed foreign shipment.'); @@ -24922,7 +24965,8 @@ function beginSpiritualAdvisor(g:Game,decision:Extract['intent']; @@ -26495,11 +26540,12 @@ function finishAdvisorReaction( advisorResume?: ResponseWindow['advisorResume']; discoveryEntry?: DiscoveryEntryArrivalChild; advisorAmbassadorEvent?: string; + advisorCaladanArrival?: CaladanEntryParent; advisorVictoryEvent?: string; advisorRevivalEvent?: string; advisorRevivalTerror?: RevivalEntryParent['terror']; advisorRemaining?: string[]; - advisorFollowup?: { shipment: string; destination: string; revivalEvent?: string; revivalTerror?: RevivalEntryParent['terror'] }; + advisorFollowup?: { shipment: string; destination: string; caladanArrival?: CaladanEntryParent; revivalEvent?: string; revivalTerror?: RevivalEntryParent['terror'] }; }, ) { const bg = byFaction(g, 'beneGesserit')!; @@ -26524,10 +26570,11 @@ function intrusion( wormRide?: boolean; discoveryEntry?: DiscoveryEntryArrivalChild; ambassadorEvent?: string; + caladanArrival?: CaladanEntryParent; victoryEvent?: string; revivalEvent?: string; revivalTerror?: RevivalEntryParent['terror']; - followup?: { shipment: string; destination: string; revivalEvent?: string; revivalTerror?: RevivalEntryParent['terror'] }; + followup?: { shipment: string; destination: string; caladanArrival?: CaladanEntryParent; revivalEvent?: string; revivalTerror?: RevivalEntryParent['terror'] }; } = {}, ) { const bg = byFaction(g, 'beneGesserit'); @@ -32188,6 +32235,7 @@ function homeworldVictoryWitness(frame: HomeworldVictoryReturn) { function homeworldVictoryReturnIntegrity(g: Game) { homeworldRule(() => validateHomeworldVictoryReturn(g)); const frame = g.homeworldVictoryReinforcement; + homeworldRule(() => validateCaladanSneakHistory(g)); if (frame?.stage === 'arrival' || frame?.stage === 'complete') homeworldRule(() => validateHomeworldArrival(g, frame, 'victoryEvent')); const continuation = g.pendingTreacheryDiscard?.continuation; @@ -32197,6 +32245,31 @@ function homeworldVictoryReturnIntegrity(g: Game) { const decisions = contexts.flatMap((context) => context?.decision?.kind === 'caladanReinforcement' ? [context.decision] : []); const arrivals = g.pendingArrivalReaction?.filter(entry => entry.cause === 'caladanReinforcement' || entry.victoryEvent !== undefined) ?? []; for (const entry of arrivals) homeworldRule(() => validateCaladanArrivalSource(g, entry)); + for (const entry of g.pendingArrivalReaction??[]) if(entry.caladanArrival) + homeworldRule(() => validateCaladanChildArrival(g,entry.caladanArrival!,entry)); + const childChoices=contexts.flatMap(context => context?.decision && + (context.decision.kind==='advisor'||context.decision.kind==='intrusion') && context.decision.caladanArrival + ? [context.decision] : []); + const childResponses=[...contexts.map(context=>context?.response), + g.pendingKarama?.use.kind==='cancel' ? g.pendingKarama.use.response : undefined] + .filter((response):response is ResponseWindow=>!!response?.advisorCaladanArrival); + for(const choice of childChoices){ + const parent=choice.caladanArrival!,packet=parent.terror; + requireRule(choice.player===byFaction(g,'beneGesserit')?.id && + (choice.kind==='intrusion' ? choice.territory===packet.territory : + choice.shipment===packet.owner && choice.destination===location(packet.territory,packet.sector)), + 'The Caladan Sneak group has lost its original Bene Gesserit opportunity.'); + homeworldRule(()=>validateCaladanChildArrival(g,parent,{entrant:packet.owner,territory:packet.territory, + sector:packet.sector,amount:packet.amount,elite:0,cause:'shipment'})); + } + for(const response of childResponses){ + const parent=response.advisorCaladanArrival!,packet=parent.terror; + requireRule(response.kind==='advisorFlip' && response.advisors===true && + response.owner===byFaction(g,'beneGesserit')?.id && response.location===packet.territory, + 'The Caladan Sneak group has lost its original Intrusion response.'); + homeworldRule(()=>validateCaladanChildArrival(g,parent,{entrant:packet.owner,territory:packet.territory, + sector:packet.sector,amount:packet.amount,elite:0,cause:'shipment'})); + } const intrusionChoices = contexts.flatMap(context => context?.decision?.kind === 'intrusion' && context.decision.victoryEvent !== undefined ? [context.decision] : []); const intrusionResponses = [...contexts.map(context => context?.response), @@ -38950,6 +39023,15 @@ function settleAutomaticContinuations(g: Game) { homeworldRule(() => validateCaladanArrivalSource(g, deferred)); const entrant = g.players.find(p => p.id === deferred.entrant); requireRule(!!entrant, 'The deferred arrival entry has lost its entrant.'); + if (deferred.caladanArrival) homeworldRule(() => validateCaladanChildArrival(g,deferred.caladanArrival!,deferred)); + if (deferred.caladanSneakAdvisor) { + requireRule(deferred.caladanArrival && !deferred.caladanArrival.spiritual, 'The queued Sneak group lost its original Caladan source.'); + const bg=byFaction(g,'beneGesserit'), source={caladanArrival:deferred.caladanArrival}; + const followup=bg && spiritualAdvisorMaximum(g,bg.id)>0 + ? {shipment:entrant!.id,destination:location(deferred.territory,deferred.sector),...source} : undefined; + if(!intrusion(g,entrant!,deferred.territory,{...source,...(followup ? {followup} : {})}) && followup) + g.decision={kind:'advisor',player:bg!.id,...followup}; + } if (deferred.revivalSneakAdvisor) { requireRule(deferred.revivalArrival?.terror && !deferred.revivalArrival.spiritual && deferred.turn === g.turn && deferred.phase === g.phase, @@ -38964,7 +39046,7 @@ function settleAutomaticContinuations(g: Game) { g.decision = {kind:'advisor',player:bg!.id,...followup}; } openTerritoryEntry(g, entrant!, deferred.territory, deferred.sector, - deferred.amount, deferred.elite, deferred.cause, deferred.resume, deferred.revivalArrival); + deferred.amount, deferred.elite, deferred.cause, deferred.resume, deferred.revivalArrival, deferred.caladanArrival); if (deferred.revivalArrival) resumeHomeworldRevivalReturn(g); continue; } @@ -40917,6 +40999,7 @@ function applyActionInner( ? ('wormRide' as const) : undefined, advisorAmbassadorEvent: decision.ambassadorEvent, + advisorCaladanArrival: decision.caladanArrival, advisorVictoryEvent: decision.victoryEvent, advisorRevivalEvent: decision.revivalEvent, advisorRevivalTerror: decision.revivalTerror, @@ -40958,7 +41041,7 @@ function applyActionInner( }; } else movementTurn(g); } else if (decision.kind === 'advisor') { - if(g.advanced||decision.revivalEvent!==undefined){ + if(g.advanced||decision.revivalEvent!==undefined||decision.caladanArrival!==undefined){ requireRule(typeof action.accept==='boolean','Choose the free Spiritual shipment or decline.'); if(action.accept){ beginSpiritualAdvisor(g,decision,action); diff --git a/game/homeworld-arrival.ts b/game/homeworld-arrival.ts index 2ca5f85c..11f3f44d 100644 --- a/game/homeworld-arrival.ts +++ b/game/homeworld-arrival.ts @@ -3,6 +3,7 @@ import { location, splitLocation, validLocation } from './board'; import { HomeworldCustodyError } from './homeworld-custody'; import type { SpiritualAdvisorSource } from './spiritual-advisor'; import { validRevivalTerrorPacket, validateRevivalTerrorPacket, type RevivalTerrorPacket } from './revival-terror-entry'; +import { validCaladanEntryParent, validateCaladanTerrorPacket, type CaladanSneakReceipt } from './caladan-arrival'; export type HomeworldAmbassadorArrival = { event: string; @@ -23,11 +24,12 @@ export type HomeworldArrivalFrame = { destination?: string; ambassadors?: HomeworldAmbassadorArrival[]; arrivalSignature?: string; + terrorArrivals?: CaladanSneakReceipt[]; }; export type HomeworldArrivalTag = 'revivalEvent' | 'victoryEvent'; export type HomeworldArrivalContext = Pick< Game, - 'turn' | 'phase' | 'players' | 'pendingAmbassador' + 'turn' | 'phase' | 'players' | 'pendingAmbassador' | 'homeworldVictoryReinforcement' >; function fail(): never { throw new HomeworldCustodyError( @@ -51,19 +53,23 @@ function keys(value: unknown, allowed: string[]): boolean { function spiritualSource( frame: HomeworldArrivalFrame, entry: HomeworldAmbassadorArrival, -): SpiritualAdvisorSource & { revivalEvent: string } { - const source = entry.spiritual as (SpiritualAdvisorSource & { revivalEvent?: string }) | undefined; - if (!source || !keys(source, ['event', 'owner', 'turn', 'phase', 'territory', 'sector', 'amount', 'revivalEvent'])) fail(); +): SpiritualAdvisorSource { + const source = entry.spiritual; + const victory = frame.phase === 6; + const tag = victory ? 'victoryEvent' : 'revivalEvent'; + if (!source || !keys(source, ['event', 'owner', 'turn', 'phase', 'territory', 'sector', 'amount', tag])) fail(); const destination = splitLocation(frame.destination!); const origin = entry.terror?.territory ?? destination.territory; if ( - Object.keys(source).sort().join(',') !== 'amount,event,owner,phase,revivalEvent,sector,territory,turn' || + Object.keys(source).sort().join(',') !== (victory + ? 'amount,event,owner,phase,sector,territory,turn,victoryEvent' + : 'amount,event,owner,phase,revivalEvent,sector,territory,turn') || !text(source.event) || !text(source.owner) || source.owner !== entry.entrant || source.owner === frame.player || - source.revivalEvent !== frame.event || - frame.phase !== 4 || source.phase !== 4 || + source[tag] !== frame.event || + (victory ? !entry.terror || source.phase !== 6 : frame.phase !== 4 || source.phase !== 4) || source.turn !== frame.turn || !Number.isSafeInteger(source.turn) || source.turn < 1 || !text(source.territory) || @@ -75,7 +81,7 @@ function spiritualSource( entry.parent !== undefined || entry.destination !== location(source.territory, source.sector) ) fail(); - return source as SpiritualAdvisorSource & { revivalEvent: string }; + return source; } function terrorPacket(source: RevivalTerrorPacket) { @@ -87,11 +93,12 @@ function terrorPacket(source: RevivalTerrorPacket) { function terrorSource(frame: HomeworldArrivalFrame, entry: HomeworldAmbassadorArrival): RevivalTerrorPacket { const source = entry.terror; const destination = splitLocation(frame.destination!); - if (!validRevivalTerrorPacket(source) || entry.parent !== undefined || + if (!source || !(frame.phase === 6 ? validCaladanEntryParent({ event: frame.event, terror: source }) : validRevivalTerrorPacket(source)) || + entry.parent !== undefined || !validLocation(destination.territory, destination.sector) || (entry.spiritual === undefined && (entry.entrant !== source.owner || entry.destination !== location(source.territory, source.sector)))) fail(); - return source; + return source!; } /** Arrival provenance is fixed separately from the original revival grant. */ @@ -166,7 +173,7 @@ export function homeworldArrivalSignature( entry.spiritual.territory, entry.spiritual.sector, entry.spiritual.amount, - spiritualSource(frame, entry).revivalEvent, + frame.phase === 6 ? spiritualSource(frame, entry).victoryEvent : spiritualSource(frame, entry).revivalEvent, ]]), ...(entry.terror === undefined ? [] : [['terror', terrorPacket(entry.terror)]]), ]), @@ -269,16 +276,24 @@ export function validateHomeworldArrival( frame.arrivalSignature !== homeworldArrivalSignature(frame) || frame.ambassadors!.some((entry) => { if (entry.terror !== undefined) { - if (tag !== 'revivalEvent') return true; - validateRevivalTerrorPacket({ turn: frame.turn, phase: frame.phase, players: g.players, - revivalPlayer: frame.player }, entry.terror, frame.destination!, frame.event); + if (tag === 'revivalEvent') { + validateRevivalTerrorPacket({ turn: frame.turn, phase: frame.phase, players: g.players, + revivalPlayer: frame.player }, entry.terror, frame.destination!, frame.event); + } else { + validateCaladanTerrorPacket({ turn: frame.turn, phase: frame.phase, players: g.players, + victoryPlayer: frame.player, terrorArrivals: frame.terrorArrivals ?? + (g.homeworldVictoryReinforcement?.event === frame.event ? g.homeworldVictoryReinforcement.terrorArrivals : undefined) ?? [] }, + entry.terror, frame.destination!, frame.event); + } } if (entry.spiritual !== undefined) { const entrants = g.players.filter((seat) => seat.id === entry.entrant); const owners = g.players.filter((seat) => seat.id === frame.player); - return tag !== 'revivalEvent' || entrants.length !== 1 || + return entrants.length !== 1 || entrants[0].faction !== 'beneGesserit' || owners.length !== 1 || - (entry.terror === undefined && owners[0].faction !== 'tleilaxu'); + (tag === 'victoryEvent' + ? frame.phase !== 6 || entry.terror === undefined || owners[0].faction !== 'atreides' + : entry.terror === undefined && owners[0].faction !== 'tleilaxu'); } if (entry.terror !== undefined) { return !g.players.some(seat => seat.id === entry.entrant && seat.faction === 'moritani'); diff --git a/game/homeworld-victory-return.ts b/game/homeworld-victory-return.ts index ce68dfbc..e0597855 100644 --- a/game/homeworld-victory-return.ts +++ b/game/homeworld-victory-return.ts @@ -2,6 +2,7 @@ import type { Game } from './engine'; import type { HomeworldAmbassadorArrival } from './homeworld-arrival'; import type { HomeworldVictoryWitness } from './homeworld-victory-reinforcement'; import { HomeworldCustodyError } from './homeworld-custody'; +import type { CaladanSneakReceipt } from './caladan-arrival'; export type HomeworldVictoryReturn = HomeworldVictoryWitness & { phase: 6; @@ -11,6 +12,7 @@ export type HomeworldVictoryReturn = HomeworldVictoryWitness & { destination?: string; ambassadors?: HomeworldAmbassadorArrival[]; arrivalSignature?: string; + terrorArrivals?: CaladanSneakReceipt[]; }; export type HomeworldVictoryObligation = { event: string; @@ -63,7 +65,7 @@ export function validateHomeworldVictoryReturn(g: Pick !['event', 'turn', 'phase', 'player', 'territory', 'result', 'stage', - 'signature', 'offer', 'destination', 'ambassadors', 'arrivalSignature'].includes(key)) || + 'signature', 'offer', 'destination', 'ambassadors', 'arrivalSignature', 'terrorArrivals'].includes(key)) || !witnessValid(frame) || frame.signature !== originalSignature(frame) || frame.phase !== 6 || !['waiting', 'choice', 'arrival', 'complete'].includes(frame.stage) || !g.homeworlds?.custody || @@ -83,8 +85,9 @@ export function validateHomeworldVictoryReturn(g: Pick, 'event' | 'turn' | 'phase' | 'stage' | 'destination'>; - homeworldVictoryReinforcement?: Pick, 'event' | 'turn' | 'phase' | 'stage'>; + homeworldVictoryReinforcement?: Pick, 'event' | 'turn' | 'phase' | 'stage'> & + Partial, 'player' | 'destination' | 'terrorArrivals'>>; pendingSpiritualAdvisor?: Pick, 'event' | 'turn' | 'phase'> & { - decision: Pick['decision'], 'player' | 'destination' | 'ambassadorEvent' | 'revivalEvent'>; + decision: Pick['decision'], 'player' | 'destination' | 'ambassadorEvent' | 'revivalEvent'> & {shipment?:string;caladanArrival?:CaladanEntryParent}; }; pendingAmbassador?: Pick, 'event' | 'revivalEvent' | 'victoryEvent' | 'turn' | 'phase' | 'stage' | 'effect' | 'shipmentReceipt'> | null; @@ -121,7 +124,8 @@ export function spiritualAdvisorMatchingKey(source: SpiritualAdvisorSource): str export function validateSpiritualAdvisorSource(ctx: SpiritualAdvisorContext, source: SpiritualAdvisorSource): void { requireSpiritual(record(source) && Object.keys(source).sort().join(',') === (source.phase === 4 - ? 'amount,event,owner,phase,revivalEvent,sector,territory,turn' : 'amount,event,owner,phase,sector,territory,turn') && + ? 'amount,event,owner,phase,revivalEvent,sector,territory,turn' : source.victoryEvent !== undefined + ? 'amount,event,owner,phase,sector,territory,turn,victoryEvent' : 'amount,event,owner,phase,sector,territory,turn') && identifier(source.event) && identifier(source.owner) && positive(source.turn) && source.turn === ctx.turn && source.phase === ctx.phase && [1, 3, 4, 5, 6, 8].includes(source.phase) && ctx.status === 'playing' && identifier(source.territory) && Number.isSafeInteger(source.sector) && source.sector >= 0 && @@ -131,6 +135,8 @@ export function validateSpiritualAdvisorSource(ctx: SpiritualAdvisorContext, sou requireSpiritual(gameTerritories(ctx).some((place) => place.id === source.territory && place.sectors.includes(source.sector)), 'Spiritual Advisors need their current board destination and sector.'); + if (source.victoryEvent !== undefined) + requireSpiritual(source.phase === 6, 'A Caladan child source belongs only to its original Battle phase.'); if (source.phase === 6) { const parent = ctx.homeworldVictoryReinforcement; requireSpiritual(record(parent) && identifier(parent.event) && parent.turn === ctx.turn && @@ -138,6 +144,22 @@ export function validateSpiritualAdvisorSource(ctx: SpiritualAdvisorContext, sou 'Victory Spiritual Advisors need their original current-turn Caladan arrival parent.'); const frame = ctx.pendingSpiritualAdvisor, ambassador = ctx.pendingAmbassador; const decision = frame?.decision; + if (decision?.caladanArrival) { + const child=decision.caladanArrival; + requireSpiritual(record(frame) && frame.event===source.event && frame.turn===ctx.turn && frame.phase===6 && + decision.player===source.owner && decision.ambassadorEvent===undefined && decision.revivalEvent===undefined && + validCaladanEntryParent(child) && child.spiritual===undefined && child.event===parent.event && + source.victoryEvent===parent.event && identifier(parent.player) && identifier(parent.destination) && + decision.shipment===child.terror.owner && decision.destination===`${child.terror.territory}:${child.terror.sector}`, + 'Victory Spiritual Advisors need their original committed Sneak Attack group.'); + try { validateCaladanTerrorPacket({turn:ctx.turn,phase:ctx.phase,players:ctx.players, + victoryPlayer:parent.player,terrorArrivals:parent.terrorArrivals??[]},child.terror,parent.destination,parent.event); } + catch { throw new SpiritualAdvisorError('Victory Spiritual Advisors lost their committed Sneak Attack history.'); } + requireSpiritual(source.territory===child.terror.territory || source.territory==='polar_sink', + 'Victory Spiritual Advisors need the actual Sneak Attack territory or Polar Sink.'); + return; + } + requireSpiritual(source.victoryEvent===undefined, 'This Guild Ambassador source does not carry a Sneak Attack grant.'); requireSpiritual(record(frame) && frame.event === source.event && frame.turn === ctx.turn && frame.phase === 6 && record(decision) && decision.player === source.owner && identifier(decision.ambassadorEvent) && decision.revivalEvent === undefined && record(ambassador) && ambassador.event === decision.ambassadorEvent && diff --git a/game/terror-entry-receipt.ts b/game/terror-entry-receipt.ts index 9be3e20a..61e20cd0 100644 --- a/game/terror-entry-receipt.ts +++ b/game/terror-entry-receipt.ts @@ -3,6 +3,7 @@ import { TERRITORIES, validLocation } from './board'; import { HomeworldCustodyError } from './homeworld-custody'; import { TERROR_STRONGHOLDS } from './moritani-terror'; import { revivalEntryParentTuple, validRevivalEntryParent } from './revival-terror-entry'; +import { caladanEntryParentTuple, validCaladanEntryParent } from './caladan-arrival'; type TerrorEntry = NonNullable; @@ -24,6 +25,7 @@ export function terrorEntrySignature(entry: TerrorEntry): string { ...(entry.nexusPlacements ? [entry.nexusPlacements] : []), ...(entry.revivalArrival === undefined ? [] : [revivalEntryParentTuple(entry.revivalArrival)]), ...(entry.victoryEvent === undefined ? [] : [['caladanVictory', entry.victoryEvent]]), + ...(entry.caladanArrival === undefined ? [] : [caladanEntryParentTuple(entry.caladanArrival)]), ]); } @@ -33,13 +35,14 @@ export function terrorSelectionSignature(entry: TerrorEntry): string { entry.turn, entry.phase, entry.resume, entry.ambassadorEvent ?? null, ...(entry.nexusPlacements ? [entry.nexusPlacements] : []), ...(entry.revivalArrival === undefined ? [] : [revivalEntryParentTuple(entry.revivalArrival)]), - ...(entry.victoryEvent === undefined ? [] : [['caladanVictory', entry.victoryEvent]])]); + ...(entry.victoryEvent === undefined ? [] : [['caladanVictory', entry.victoryEvent]]), + ...(entry.caladanArrival === undefined ? [] : [caladanEntryParentTuple(entry.caladanArrival)])]); } /** Older saves without a receipt remain explicit legacy state. Never invent * their original entry count from the destination's present occupants. */ export function validateTerrorEntrySignature(entry: TerrorEntry): void { - if (entry.entrySignature === undefined && entry.candidates === undefined && entry.selectionSignature === undefined && entry.nexusPlacements === undefined && entry.revivalArrival === undefined && entry.victoryEvent === undefined && entry.cause !== 'caladanReinforcement' && entry.stage !== 'select') return; + if (entry.entrySignature === undefined && entry.candidates === undefined && entry.selectionSignature === undefined && entry.nexusPlacements === undefined && entry.revivalArrival === undefined && entry.caladanArrival === undefined && entry.victoryEvent === undefined && entry.cause !== 'caladanReinforcement' && entry.stage !== 'select') return; if ( typeof entry.entrySignature !== 'string' || typeof entry.token !== 'string' || @@ -74,6 +77,8 @@ export function validateTerrorEntrySignature(entry: TerrorEntry): void { entry.phase < 0 || entry.phase > 8 || (entry.revivalArrival !== undefined && !validRevivalEntryParent(entry.revivalArrival)) || + (entry.caladanArrival !== undefined && (!validCaladanEntryParent(entry.caladanArrival) || + entry.victoryEvent !== undefined || entry.revivalArrival !== undefined)) || (entry.victoryEvent !== undefined && (typeof entry.victoryEvent !== 'string' || !entry.victoryEvent || entry.cause !== 'caladanReinforcement' || entry.phase !== 6 || entry.revivalArrival !== undefined)) || (![ diff --git a/tests/caladan-sneak-engine.test.ts b/tests/caladan-sneak-engine.test.ts new file mode 100644 index 00000000..14195aca --- /dev/null +++ b/tests/caladan-sneak-engine.test.ts @@ -0,0 +1,223 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { isAdvisor } from '../game/advisors'; +import { + createCaladanSneakFixture as fixture, openCaladanSneak as open, + commitCaladanSneak as commit, actCaladanSneak as act, + settleCaladanSneak as settle, passCaladanSneakResponse as pass, + allowCaladanSneakResponses as allow, caladanSneakPlayer as p, + caladanSneakInventory as inventory, caladanSneakReload as reload, +} from './fixture-caladan-sneak'; + +function reject(game: Game, actor: string, action: Action) { + const before = reload(game); + assert.throws(() => applyAction(game,actor,action)); + assert.deepEqual(game,before); +} +function complete(game: Game, event: string) { + assert.equal(game.homeworldVictoryReinforcement!.event,event); + assert.equal(game.homeworldVictoryReinforcement!.stage,'complete'); + assert.equal(game.lastBattleContext!.caladanReinforcement!.completed,true); + assert.equal(p(game,'a').reserves,5); + assert.equal(p(game,'a').forces['arrakeen:10'],3); + inventory(game); +} +function declineChildAmbassador(game: Game) { + const entry = game.pendingAmbassador; + assert.ok(entry); + return act(game,'ec',{type:'decision',event:entry.event,decline:true}); +} + +void test('Caladan positive Sneak commits one distinct reserve group with one actual original receipt', () => { + const f = fixture({bg:'none'}), opened = open(f); + assert.match(f.disclosure,/Controlled.*no installed victory or child receipt/); + assert.equal(f.sourceCount,f.sourceActions.length); + assert.equal(f.sourceActions.filter(s => s.action.type === 'battlePlan').length,2); + assert.equal(f.sourceActions.filter(s => s.action.type === 'traitorCall').length,2); + assert.equal(f.source.homeworldVictoryReinforcement,undefined); + const before = reload(opened.game); + const game = settle(act(opened.game,'m',{type:'decision',amount:1})); + assert.equal(p(game,'m').reserves,p(before,'m').reserves-1); + assert.equal(p(game,'m').forces['arrakeen:10'],1); + for (const id of ['a','m']) for (const field of ['spice','shipped','moved'] as const) + assert.deepEqual(p(game,id)[field],p(before,id)[field]); + const receipts = game.homeworldVictoryReinforcement!.terrorArrivals!; + assert.equal(receipts.length,1); + assert.equal(receipts[0].packet.owner,'m'); + assert.equal(receipts[0].packet.territory,'arrakeen'); + assert.equal(receipts[0].packet.sector,10); + assert.equal(receipts[0].packet.amount,1); + assert.equal(receipts[0].packet.elite,0); + assert.notEqual(receipts[0].packet.event,f.event); + assert.equal(game.moritaniTerror!.tokens.find(t => t.id === f.token)!.status,'removed'); + complete(game,f.event); + reject(game,'a',f.action); +}); + +void test('Caladan zero Sneak spends no reserve and creates no child or free advisor source', () => { + const f = fixture({bg:'away'}), before = open(f); + const game = settle(act(before.game,'m',{type:'decision',amount:0})); + assert.equal(p(game,'m').reserves,p(before.game,'m').reserves); + assert.equal(p(game,'m').forces['arrakeen:10'],undefined); + assert.equal(game.homeworldVictoryReinforcement!.terrorArrivals?.length ?? 0,0); + assert.equal(game.pendingSpiritualAdvisor,undefined); + assert.equal(game.pendingArrivalReaction?.length ?? 0,0); + assert.equal(game.pendingAmbassador??null,null); + complete(game,f.event); +}); + +for (const order of ['first','second'] as const) + void test(`Caladan Sneak preserves the original Ambassador ${order} before its second child offer`, () => { + const f = fixture({bg:'none',ambassador:order}), opened = open(f); + const original = structuredClone(opened.game.pendingArrivalOverlap); + assert.ok(original); + assert.equal(original.victoryEvent,f.event); + let game = act(opened.game,'m',{type:'decision',amount:1}); + if (order === 'second') { + assert.equal(game.pendingAmbassador?.entrant,'a'); + assert.equal(game.pendingAmbassador?.event,game.pendingArrivalOverlap?.ambassadorEvent); + assert.equal(game.pendingArrivalOverlap?.event,original.event); + assert.equal(game.homeworldVictoryReinforcement!.stage,'arrival'); + const queued = game.pendingArrivalReaction!.find(entry => entry.entrant === 'm')!; + assert.ok(queued); + assert.equal(queued.caladanSneakAdvisor,true); + assert.equal(queued.caladanArrival?.event,f.event); + assert.equal(queued.caladanArrival?.terror?.owner,'m'); + game = declineChildAmbassador(game); + } + assert.equal(game.pendingArrivalOverlap,null); + assert.equal(game.pendingAmbassador?.entrant,'m'); + assert.equal(game.pendingAmbassador?.token,f.ambassadorToken); + assert.equal(game.pendingAmbassador?.victoryEvent,f.event); + const records = game.homeworldVictoryReinforcement!.ambassadors!; + assert.equal(records.length,2); + assert.deepEqual(records.map(record => record.completed),[true,false]); + assert.equal(records[0].entrant,'a'); + assert.equal(records[1].entrant,'m'); + assert.equal(records[1].terror?.event,game.homeworldVictoryReinforcement!.terrorArrivals![0].packet.event); + assert.notEqual(records[0].event,records[1].event); + reject(game,'a',f.action); + game = settle(declineChildAmbassador(game)); + assert.deepEqual(game.homeworldVictoryReinforcement!.ambassadors!.map(record => record.completed),[true,true]); + assert.equal(p(game,'m').reserves,19); + assert.equal(p(game,'m').forces['arrakeen:10'],1); + complete(game,f.event); + }); + +void test('Caladan Sneak Advanced accompaniment uses its own committed source with away or resident advisors', () => { + for (const bg of ['away','advisors'] as const) { + const f = fixture({bg}), committed = commit(f); + let game = committed.game; + assert.equal(game.decision?.kind,'advisor'); + assert.ok(game.decision?.kind === 'advisor' && game.decision.caladanArrival); + if (game.decision?.kind !== 'advisor') assert.fail('The committed Sneak group owns its advisor offer.'); + const parent = game.decision.caladanArrival!; + assert.equal(parent.event,f.event); + assert.equal(parent.terror?.owner,'m'); + assert.equal(parent.terror?.amount,1); + const before = reload(game), original = structuredClone(game.homeworldVictoryReinforcement!.terrorArrivals); + game = act(game,'bg',{type:'decision',accept:true,accompany:true}); + assert.equal(game.response?.kind,'advisor'); + assert.equal(game.response?.spiritualAdvisorSource?.phase,6); + assert.equal(game.response?.spiritualAdvisorSource?.victoryEvent,f.event); + assert.deepEqual(game.pendingSpiritualAdvisor!.decision.caladanArrival,parent); + assert.equal(game.homeworldVictoryReinforcement!.stage,'arrival'); + assert.equal(p(game,'bg').reserves,p(before,'bg').reserves); + game = allow(game); + assert.equal(p(game,'bg').forces['arrakeen:10'],bg === 'advisors' ? 2 : 1); + assert.equal(p(game,'bg').reserves,p(before,'bg').reserves-1); + assert.equal(isAdvisor(p(game,'bg'),'arrakeen'),true); + assert.deepEqual(game.homeworldVictoryReinforcement!.terrorArrivals,original); + complete(game,f.event); + } +}); + +for (const prevented of [false,true]) + void test(`Caladan Sneak Polar arrival ${prevented ? 'prevents' : 'allows'} the distinct automatic conversion`, () => { + const f = fixture({bg:'away',polarAlone:true}), committed = commit(f); + let game = act(committed.game,'bg',{type:'decision',accept:true,accompany:false}); + assert.equal(game.pendingSpiritualAdvisor?.stage,'shipping'); + game = pass(game); + assert.equal(game.response?.automaticAdvisorFlip?.producer,'spiritualArrival'); + assert.equal(game.pendingSpiritualAdvisor?.stage,'arrival'); + assert.equal(p(game,'bg').forces['polar_sink:0'],1); + assert.equal(isAdvisor(p(game,'bg'),'polar_sink'),true); + const physical = structuredClone(p(game,'bg')); + game = prevented ? act(game,'g',{type:'card',card:f.karama,mode:'cancel'}) : pass(game); + game = settle(game); + assert.equal(isAdvisor(p(game,'bg'),'polar_sink'),prevented); + assert.equal(p(game,'bg').forces['polar_sink:0'],physical.forces['polar_sink:0']); + assert.equal(p(game,'bg').reserves,physical.reserves); + assert.equal(p(game,'m').forces['arrakeen:10'],1); + assert.equal(game.discard.filter(c => c.id === f.karama).length,Number(prevented)); + assert.equal(game.homeworldVictoryReinforcement!.terrorArrivals!.length,1); + complete(game,f.event); + }); + +void test('Caladan Sneak shipping prevention leaves the committed group and original cost without replay or refund', () => { + const f = fixture(), committed = commit(f); + const before = reload(committed.game); + let game = act(committed.game,'bg',{type:'decision',accept:true,accompany:true}); + assert.equal(game.response?.kind,'advisor'); + game = settle(act(game,'g',{type:'card',card:f.karama,mode:'cancel'})); + assert.equal(p(game,'bg').reserves,p(before,'bg').reserves); + assert.equal(p(game,'bg').forces['arrakeen:10'],undefined); + assert.equal(p(game,'m').forces['arrakeen:10'],1); + assert.equal(p(game,'m').reserves,19); + assert.equal(game.discard.filter(c => c.id === f.karama).length,1); + assert.equal(game.homeworldVictoryReinforcement!.terrorArrivals!.length,1); + complete(game,f.event); + reject(game,'bg',{type:'decision',accept:true,accompany:true}); +}); + +void test('Caladan Sneak decline consumes only the original optional advisor choice', () => { + const f = fixture(), committed = commit(f), before = structuredClone(p(committed.game,'bg')); + const game = settle(act(committed.game,'bg',{type:'decision',accept:false})); + assert.deepEqual(p(game,'bg'),before); + assert.ok(p(game,'g').hand.some(c => c.id === f.karama)); + assert.equal(game.pendingSpiritualAdvisor,undefined); + assert.equal(game.homeworldVictoryReinforcement!.terrorArrivals!.length,1); + complete(game,f.event); +}); + +void test('Caladan Sneak Basic fallback uses the legal original free Polar shipment', () => { + const f = fixture({advanced:false}), committed = commit(f), before = reload(committed.game); + let game = act(committed.game,'bg',{type:'decision',accept:true,accompany:false}); + assert.equal(game.response?.spiritualAdvisorSource?.victoryEvent,f.event); + game = allow(game); + assert.equal(p(game,'bg').forces['polar_sink:0'],1); + assert.equal(p(game,'bg').reserves,p(before,'bg').reserves-1); + assert.equal(isAdvisor(p(game,'bg'),'polar_sink'),false); + assert.equal(p(game,'bg').spice,p(before,'bg').spice); + complete(game,f.event); +}); + +void test('Caladan Sneak keeps native owner and amount validation while the original choice is busy', () => { + const f = fixture(), opened = open(f); + reject(opened.game,'a',{type:'decision',amount:1}); + reject(opened.game,'m',{type:'decision',amount:-1}); + reject(opened.game,'a',f.action); + const committed = commit(f); + reject(committed.game,'a',{...f.action,event:`${f.event}:stale`}); + reject(committed.game,'g',{type:'decision',accept:true,accompany:true}); + assert.equal(p(committed.game,'m').reserves,19); + assert.equal(committed.game.homeworldVictoryReinforcement!.terrorArrivals!.length,1); + inventory(committed.game); +}); + +void test('all four minimal profiles can answer the actual Caladan Sneak advisor opportunity legally', () => { + const f = fixture(), committed = commit(f); + for (const profile of ['Easy','Medium','Hard','Brutal'] as const) { + let game = reload(committed.game); + const own = viewGame(game,'bg'); + own.players.find(player => player.id === 'bg')!.bot = profile; + const action = botActions(own)[0]; + assert.ok(action,`${profile} needs a legal original advisor choice.`); + game = allow(act(game,'bg',action)); + assert.equal(game.homeworldVictoryReinforcement!.terrorArrivals!.length,1); + assert.equal(p(game,'m').reserves,19); + complete(game,f.event); + } +}); diff --git a/tests/caladan-sneak-source.test.ts b/tests/caladan-sneak-source.test.ts new file mode 100644 index 00000000..581deab8 --- /dev/null +++ b/tests/caladan-sneak-source.test.ts @@ -0,0 +1,166 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createGame, newPlayer, type Game } from '../game/engine'; +import { createHomeworldCustody, HomeworldCustodyError } from '../game/homeworld-custody'; +import { homeworldContext } from '../game/homeworld-game'; +import { createTerrorState } from '../game/moritani-terror'; +import { terrorEntrySignature, terrorSelectionSignature } from '../game/terror-entry-receipt'; +import { + makeHomeworldVictoryReturn, homeworldVictoryOfferSignature, homeworldVictoryObligationSignature, +} from '../game/homeworld-victory-return'; +import { + appendHomeworldArrivalAmbassador, completeHomeworldArrivalAmbassador, homeworldArrivalSignature, + validateHomeworldArrival, type HomeworldArrivalFrame, +} from '../game/homeworld-arrival'; +import { + createCaladanSneakReceipt, validateCaladanSneakHistory, validateCaladanTerrorPacket, + validCaladanEntryParent, caladanEntryParentTuple, validateCaladanChildArrival, + type CaladanEntryParent, type CaladanTerrorHistoryContext, +} from '../game/caladan-arrival'; + +const uuid = '00112233-4455-4677-8899-aabbccddeeff'; +const otherUUID = '11112233-4455-4677-8899-aabbccddeeff'; + +/** Controlled provenance unit: signed victory/arrival records, selected removed + * Sneak token and private stacked candidates are scenario inputs. This does not + * claim a native battle, placement, reserve debit or second-token chronology. */ +function fixture() { + const g = createGame('CALADANSNEAKSOURCE', newPlayer('a', 'Atreides', 'atreides'), true, ['ecaz']); + g.players.push(newPlayer('m', 'Moritani', 'moritani'), newPlayer('bg', 'BG', 'beneGesserit'), newPlayer('ec', 'Ecaz', 'ecaz')); + Object.assign(g, { status: 'playing', turn: 2, phase: 6 }); + Object.assign(g.players[0], { reserves: 5, tanks: 12, forces: { 'arrakeen:10': 3 } }); + g.homeworlds = { custody: createHomeworldCustody(homeworldContext(g)) }; + const context = { event: 'original-victory', turn: 2, territory: 'arrakeen', winner: 'a', result: 'normal' } as NonNullable; + const frame = makeHomeworldVictoryReturn({ event: context.event, turn: 2, player: 'a', territory: 'arrakeen', result: 'normal' }); + frame.stage = 'arrival'; frame.destination = 'arrakeen:10'; frame.ambassadors = []; + frame.offer = { population: 6, survivors: 2, blocked: null, + signature: homeworldVictoryOfferSignature(frame, { population: 6, survivors: 2, blocked: null }) }; + frame.arrivalSignature = homeworldArrivalSignature(frame); + context.caladanReinforcement = { event: frame.event, completed: false, stage: 'arrival', + signature: homeworldVictoryObligationSignature(context, false, 'arrival') }; + g.lastBattleContext = context; g.homeworldVictoryReinforcement = frame; + g.moritaniTerror = createTerrorState(() => 0); + const token = g.moritaniTerror.tokens.find(candidate => candidate.kind === 'sneakAttack')!; + token.status = 'removed'; token.location = null; + const hidden = g.moritaniTerror.tokens.filter(candidate => candidate.id !== token.id).slice(0, 2); + for (const candidate of hidden) { candidate.status = 'placed'; candidate.location = 'arrakeen'; } + const entry: NonNullable = { token: token.id, + candidates: [token.id, ...hidden.map(candidate => candidate.id)], entrant: 'a', territory: 'arrakeen', + sector: 10, amount: 1, elite: 0, cause: 'caladanReinforcement', victoryEvent: frame.event, + turn: 2, phase: 6, stage: 'sneakAttack', resume: 'none' }; + entry.selectionSignature = terrorSelectionSignature(entry); entry.entrySignature = terrorEntrySignature(entry); + g.pendingTerrorEntry = entry; g.decision = { kind: 'moritaniTerror', player: 'm', entrant: 'a', territory: 'arrakeen' }; + return { g, frame, entry, hidden: hidden.map(candidate => candidate.id) }; +} +function committed(amount = 2) { + const f = fixture(), result = createCaladanSneakReceipt(f.g, f.entry, 'm', amount, uuid); + // Controlled ledger append represents the caller's post-transfer step. This + // unit does not claim to have operated the native transfer itself. + f.frame.terrorArrivals = [result.receipt]; + return { ...f, ...result }; +} +function history(f: ReturnType): CaladanTerrorHistoryContext { + return { turn: f.frame.turn, phase: 6, players: f.g.players, victoryPlayer: f.frame.player, + terrorArrivals: f.frame.terrorArrivals! }; +} + +void test('Caladan Sneak quotes an opaque public packet and sanitized used-token history without private stack identities', () => { + const f = fixture(), before = structuredClone(f.g); + const result = createCaladanSneakReceipt(f.g, f.entry, 'm', 2, uuid); + assert.deepEqual(result.packet, { event: uuid, owner: 'm', territory: 'arrakeen', sector: 10, amount: 2, elite: 0 }); + assert.notEqual(result.packet.event, f.entry.entrySignature); + assert.equal(result.receipt.token, f.entry.token); + assert.deepEqual(Object.keys(result.receipt.trigger).sort(), ['amount', 'cause', 'elite', 'entrant', 'sector', 'territory', 'victoryEvent']); + for (const id of f.hidden) { + assert.ok(!JSON.stringify(result.packet).includes(id)); + assert.ok(!JSON.stringify(result.receipt).includes(id)); + } + assert.doesNotMatch(JSON.stringify(result.receipt), /candidates|selectionSignature|entrySignature|"players"|"hand"/); + assert.notEqual(result.packet, result.receipt.packet); + assert.deepEqual(f.g, before); + f.g.decision = null; // applyAction clears its owned decision during dispatch. + assert.doesNotThrow(() => createCaladanSneakReceipt(f.g, f.entry, 'm', 2, otherUUID)); +}); + +void test('the current selected Sneak stage owner source and positive packet count remain required before debit', () => { + for (const variant of ['owner', 'zero', 'oversize', 'raw-event', 'stage', 'source'] as const) { + const f = fixture(); + const entry = variant === 'source' ? { ...f.entry, amount: 2 } : f.entry; + if (variant === 'stage') f.entry.stage = 'offer'; + const before = structuredClone(f.g); + assert.throws(() => createCaladanSneakReceipt(f.g, entry, variant === 'owner' ? 'ec' : 'm', + variant === 'zero' ? 0 : variant === 'oversize' ? 6 : 2, + variant === 'raw-event' ? f.entry.entrySignature! : uuid), HomeworldCustodyError); + assert.deepEqual(f.g, before); + } +}); + +void test('opaque history validates its original source after resources token custody and the latest battle have changed', () => { + const f = committed(); + validateCaladanTerrorPacket(history(f), f.packet, f.frame.destination!, f.frame.event); + validateCaladanSneakHistory(f.g); + f.g.players.find(player => player.id === 'm')!.reserves = 0; + f.g.players[0].forces = {}; + f.g.moritaniTerror!.tokens = []; + f.frame.stage = 'complete'; f.g.lastBattleContext = undefined; f.g.turn = 3; f.g.phase = 8; + assert.doesNotThrow(() => validateCaladanSneakHistory(f.g)); + const modified = { ...f.packet, amount: 3 }; + assert.throws(() => validateCaladanTerrorPacket(history(f), modified, f.frame.destination!, f.frame.event), HomeworldCustodyError); + assert.throws(() => validateCaladanTerrorPacket(history(f), f.packet, 'carthag:11', f.frame.event), HomeworldCustodyError); +}); + +void test('new Moritani and Spiritual child entries bind their own packets and cannot reuse the original Caladan force', () => { + const f = committed(), parent: CaladanEntryParent = { event: f.frame.event, terror: f.packet }; + const direct = { entrant: 'm', territory: 'arrakeen', sector: 10, amount: 2, elite: 0, cause: 'shipment' }; + assert.ok(validCaladanEntryParent(parent)); + validateCaladanChildArrival(f.g, parent, direct); + assert.throws(() => validateCaladanChildArrival(f.g, parent, { ...direct, entrant: 'a', amount: 1, cause: 'caladanReinforcement' }), HomeworldCustodyError); + const spiritual: CaladanEntryParent = { ...parent, spiritual: { event: 'actual-bg-packet', owner: 'bg', turn: 2, + phase: 6, territory: 'polar_sink', sector: 0, amount: 1, victoryEvent: f.frame.event } }; + const advisor = { entrant: 'bg', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, cause: 'advisor' }; + validateCaladanChildArrival(f.g, spiritual, advisor); + assert.deepEqual(caladanEntryParentTuple(spiritual), ['caladanArrival', f.frame.event, + ['actual-bg-packet', 'bg', 2, 6, 'polar_sink', 0, 1, f.frame.event], + [uuid, 'm', 'arrakeen', 10, 2, 0]]); + assert.equal(validCaladanEntryParent({ event: f.frame.event, spiritual: spiritual.spiritual }), false); + assert.equal(validCaladanEntryParent({ ...parent, spiritual: undefined }), false); + assert.throws(() => validateCaladanChildArrival(f.g, spiritual, { ...advisor, amount: 2 }), HomeworldCustodyError); + assert.throws(() => validateCaladanChildArrival(f.g, { ...parent, terror: { ...f.packet, event: otherUUID } }, direct), HomeworldCustodyError); +}); + +void test('phase-six Ambassador history resolves opaque Caladan packets and their Spiritual roots without storing private stack signatures', () => { + const f = committed(); + const entry: NonNullable = { event: 'sneak-ambassador', owner: 'ec', entrant: 'm', + territory: 'arrakeen', sector: 10, turn: 2, phase: 6, victoryEvent: f.frame.event, + token: 'controlled-ambassador-token', stage: 'offer', copyChoices: [], resume: 'none' }; + appendHomeworldArrivalAmbassador(f.frame, entry, undefined, undefined, f.packet); + f.g.pendingAmbassador = entry; + validateHomeworldArrival(f.g, f.frame, 'victoryEvent'); + completeHomeworldArrivalAmbassador(f.frame, entry.event); f.g.pendingAmbassador = null; + const source = { event: 'spiritual-packet', owner: 'bg', turn: 2, phase: 6, + territory: 'polar_sink', sector: 0, amount: 1, victoryEvent: f.frame.event }; + const bg = { ...entry, event: 'spiritual-ambassador', entrant: 'bg', territory: 'polar_sink', sector: 0 }; + appendHomeworldArrivalAmbassador(f.frame, bg, undefined, source, f.packet); f.g.pendingAmbassador = bg; + validateHomeworldArrival(f.g, f.frame, 'victoryEvent'); + for (const id of f.hidden) assert.ok(!f.frame.arrivalSignature!.includes(id)); + assert.ok(!f.frame.arrivalSignature!.includes(f.entry.entrySignature!)); +}); + +void test('legacy phase-four Spiritual bytes and phase-six Guild child bytes stay unchanged', () => { + const old: HomeworldArrivalFrame = { event: 'old-return', turn: 2, phase: 4, player: 't', stage: 'arrival', + signature: 'old-original-signature', destination: 'arrakeen:10', ambassadors: [] }; + old.arrivalSignature = homeworldArrivalSignature(old); + const source = { event: 'old-spiritual', owner: 'bg', turn: 2, phase: 4, territory: 'arrakeen', sector: 10, + amount: 1, revivalEvent: old.event }; + appendHomeworldArrivalAmbassador(old, { event: 'old-ambassador', entrant: 'bg', territory: 'arrakeen', sector: 10, + turn: 2, phase: 4 }, undefined, source); + assert.equal(old.arrivalSignature, JSON.stringify(['homeworldRevivalArrival', old.signature, old.destination, + [['old-ambassador', 'bg', 'arrakeen:10', null, false, ['old-spiritual', 'bg', 2, 4, 'arrakeen', 10, 1, old.event]]]])); + const guild: HomeworldArrivalFrame = { event: 'old-victory', turn: 2, phase: 6, player: 'a', stage: 'arrival', + signature: 'old-victory-signature', destination: 'arrakeen:10', ambassadors: [ + { event: 'guild-parent', entrant: 'a', destination: 'arrakeen:10', completed: true }, + { event: 'guild-bg-child', entrant: 'bg', destination: 'polar_sink:0', parent: 'guild-parent', completed: false }, + ] }; + assert.equal(homeworldArrivalSignature(guild), JSON.stringify(['homeworldRevivalArrival', guild.signature, guild.destination, + [['guild-parent', 'a', 'arrakeen:10', null, true], ['guild-bg-child', 'bg', 'polar_sink:0', 'guild-parent', false]]])); +}); diff --git a/tests/fixture-caladan-sneak.ts b/tests/fixture-caladan-sneak.ts new file mode 100644 index 00000000..bda75a43 --- /dev/null +++ b/tests/fixture-caladan-sneak.ts @@ -0,0 +1,155 @@ +import assert from 'node:assert/strict'; +import { viewGame, type Action, type Game } from '../game/engine'; +import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors'; +import { placeTerror } from '../game/moritani-terror'; +import { location, territory } from '../game/board'; +import { caladanVictoryFixture, holdVictoryCard } from './fixture-caladan-victory'; +import { + actCaladanArrival, caladanArrivalInventory, caladanArrivalPlayer, + caladanArrivalReload, type CaladanArrivalStep, +} from './fixture-caladan-arrival'; +import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions'; + +export const actCaladanSneak = actCaladanArrival; +export const caladanSneakPlayer = caladanArrivalPlayer; +export const caladanSneakReload = caladanArrivalReload; +export const caladanSneakInventory = caladanArrivalInventory; +export type CaladanSneakStep = CaladanArrivalStep; + +/** Neutral native controls only. All entry decisions and the current original + * reinforcement remain owned by the caller; no later battle is started. */ +export function settleCaladanSneak(game: Game, actions: CaladanSneakStep[] = []): Game { + let g = game; + for (let n = 0; n < 120; n++) { + if (g.homeworldVictoryReinforcement?.stage === 'complete') return g; + if (g.decision && ['caladanReinforcement','moritaniTerror','ecazAmbassador', + 'intrusion','advisor','arrivalOverlap'].includes(g.decision.kind)) return g; + if (!g.battle && !g.response && !g.pendingTreacheryDiscard) return g; + const next = nextStrongholdFactionsNativeStep(g); + if (!next) return g; + g = actCaladanSneak(g,next.actor,next.action,actions); + } + assert.fail('The bounded Caladan Sneak continuation did not stop.'); +} + +/** Finish exactly the present preventable layer; a later matching/automatic + * advisor conversion remains observable instead of being folded into it. */ +export function passCaladanSneakResponse(game: Game, actions: CaladanSneakStep[] = []): Game { + let g = game; + const signature = (state: Game) => state.response ? JSON.stringify([ + state.response.kind, state.response.spiritualAdvisorSource, + state.response.automaticAdvisorFlip, + ]) : null; + const original = signature(g); + assert.ok(original); + for (let n = 0; signature(g) === original && n < 40; n++) { + const actor = g.players.find(p => !g.response!.passed.includes(p.id)); + assert.ok(actor); + g = actCaladanSneak(g,actor.id,{type:'passResponse'},actions); + } + assert.notEqual(signature(g),original); + return g; +} + +export function allowCaladanSneakResponses(game: Game, actions: CaladanSneakStep[] = []): Game { + let g = game; + for (let n = 0; g.response && n < 40; n++) g = passCaladanSneakResponse(g,actions); + assert.equal(g.response,null); + return settleCaladanSneak(g,actions); +} + +export function createCaladanSneakFixture(options: { + advanced?: boolean; bg?: 'none'|'away'|'advisors'; + ambassador?: 'first'|'second'; polarAlone?: boolean; +} = {}) { + const advanced = options.advanced ?? true, bg = options.bg ?? 'away'; + let game = caladanVictoryFixture({advanced,extraSeats:[ + {id:'m',faction:'moritani'}, + ...(options.ambassador ? [{id:'ec',faction:'ecaz'} as const] : []), + ...(bg !== 'none' ? [{id:'bg',faction:'beneGesserit'} as const] : []), + ]}); + // Explicit controlled source seam: the original Homeworld preview setup is + // genuine, while these later positions/purses/card custody and token placement + // are conserved scenario inputs, not an acquisition or movement history. + game.expansions.push('ecaz'); + for (const id of ['a','g']) { + const p = caladanSneakPlayer(game,id); + delete p.forces['hagga_basin:12']; p.forces['arrakeen:10'] = 3; + } + const a = caladanSneakPlayer(game,'a'), guild = caladanSneakPlayer(game,'g'); + if (options.polarAlone) { + a.forces['hagga_basin:12'] = a.forces['polar_sink:0']; + delete a.forces['polar_sink:0']; + } + if (bg !== 'none') { + const p = caladanSneakPlayer(game,'bg'); + Object.assign(p,{reserves:19,forces:bg === 'advisors' ? {'arrakeen:10':1} : {'sietch_tabr:14':1}, + advisors:advanced && bg === 'advisors' ? {arrakeen:{}} : {}}); + } + const token = game.moritaniTerror!.tokens.find(t => t.kind === 'sneakAttack')!.id; + game.moritaniTerror = placeTerror(game.moritaniTerror!,token,'arrakeen',1); + let ambassadorToken: string | undefined; + if (options.ambassador) { + game.ecazAmbassadors = createAmbassadors(() => 0); + const original = game.ecazAmbassadors.tokens.find(t => t.zone === 'supply')!; + ambassadorToken = original.id; + game.ecazAmbassadors = placeAmbassador(game.ecazAmbassadors,original.id,{turn:1,availableSpice:20, + destination:{id:'arrakeen',stronghold:true,inStorm:false,allowed:true}}).state; + game.order = options.ambassador === 'first' ? ['a','g','ec','m'] : ['a','g','m','ec']; + if (bg !== 'none') game.order.push('bg'); + } + const karama = holdVictoryCard(game,'g','karama'); + // Keep another real controlled contest so entry assertions exclude unrelated + // automatic Collection income after this one battle finishes. + const contest = location('shield_wall',territory('shield_wall').sectors[0]); + const donor = options.polarAlone ? 'hagga_basin:12' : 'polar_sink:0'; + a.forces[donor]--; a.forces[contest] = 1; + guild.reserves--; guild.forces[contest] = 1; + caladanSneakInventory(game); + const source = caladanSneakReload(game), sourceActions: CaladanSneakStep[] = []; + game = actCaladanSneak(game,'a',{type:'chooseBattle',territory:'arrakeen',target:'g'},sourceActions); + game = settleCaladanSneak(game,sourceActions); + assert.ok(game.battle && !game.battle.preparation && !game.decision && !game.response); + const aLeader = caladanSneakPlayer(game,'a').leaders.reduce((best,l) => l.strength > best.strength ? l : best); + const gLeader = caladanSneakPlayer(game,'g').leaders.reduce((best,l) => l.strength < best.strength ? l : best); + game = actCaladanSneak(game,'a',{type:'battlePlan',leader:aLeader.id,dial:1,support:advanced ? 1 : 0},sourceActions); + game = actCaladanSneak(game,'g',{type:'battlePlan',leader:gLeader.id,dial:0,support:0},sourceActions); + game = settleCaladanSneak(game,sourceActions); + assert.equal(game.decision?.kind,'caladanReinforcement'); + assert.equal(caladanSneakPlayer(game,'a').forces['arrakeen:10'],2); + const event = game.homeworldVictoryReinforcement!.event; + caladanSneakInventory(game); + return {game,source,sourceActions,actions:[...sourceActions],sourceCount:sourceActions.length, + disclosure:'Controlled Homeworld preview initializer and conserved later phase/positions/purses/cards/original token supply and placement; only recorded battle/arrival actions are native chronology, with no installed victory or child receipt.', + advanced,bg,ambassador:options.ambassador,token,ambassadorToken,karama,event, + territory:'arrakeen',destination:'arrakeen:10', + action:{type:'decision',event,destination:'arrakeen:10',amount:1} as Action, + offer:viewGame(game,'a').caladanReinforcement!}; +} + +export type CaladanSneakFixture = ReturnType; + +/** Reach the original revealed Sneak amount choice through actual decisions. + * If Ambassador was first, its actual decline leaves the original token in + * place so the later Moritani group can legitimately offer it again. */ +export function openCaladanSneak(f: CaladanSneakFixture): CaladanSneakFixture { + const actions = [...f.sourceActions]; + let game = actCaladanSneak(caladanSneakReload(f.game),'a',f.action,actions); + assert.equal(game.pendingSpiritualAdvisor,undefined,'A direct Caladan addition creates no free-shipment source.'); + if (game.pendingAmbassador) { + assert.equal(game.pendingAmbassador.entrant,'a'); + game = actCaladanSneak(game,'ec',{type:'decision',event:game.pendingAmbassador.event,decline:true},actions); + } + assert.equal(game.pendingTerrorEntry?.stage,'offer'); + assert.equal(game.pendingTerrorEntry?.victoryEvent,f.event); + game = actCaladanSneak(game,'m',{type:'decision',reveal:true},actions); + assert.equal(game.pendingTerrorEntry?.stage,'sneakAttack'); + return {...f,game,actions}; +} + +export function commitCaladanSneak(f: CaladanSneakFixture, amount: 0|1 = 1): CaladanSneakFixture { + const opened = openCaladanSneak(f), actions = [...opened.actions]; + const game = actCaladanSneak(opened.game,'m',{type:'decision',amount},actions); + caladanSneakInventory(game); + return {...opened,game,actions}; +}