diff --git a/components/caladan-reinforcement.tsx b/components/caladan-reinforcement.tsx index 8d9dac1b..a65f7402 100644 --- a/components/caladan-reinforcement.tsx +++ b/components/caladan-reinforcement.tsx @@ -40,6 +40,17 @@ function ReinforcementChoice({ game, act, busy }: Props) {

Add one reserve force at the battle location, or leave it in reserves.

+

+ This force joins the original surviving winner at the original battle location, including inside + the Hidden Mobile Stronghold. Its direct-shipment ban does not block this placement. No spice, + shipment, movement or revival allowance is spent, and no Spiritual Advisor opportunity is created. + HMS entry is provisional. Storm-sector entry remains unavailable. +

+

+ {game.advanced && 'Advanced Bene Gesserit Intrusion resolves first. '} + Provisional arrival classification: eligible Ambassador and Terror reactions then follow their + original arrival order. +

Provisional order: this optional force joins the original victorious army before any later Face Dance. If replacement occurs, it returns with @@ -80,7 +91,7 @@ function ReinforcementChoice({ game, act, busy }: Props) { className="game-action" disabled={busy || !choice.action} onClick={() => { - if (choice.action) act(choice.action); + if (!busy && choice.action) act(choice.action); }} > Add one reserve force @@ -89,9 +100,8 @@ function ReinforcementChoice({ game, act, busy }: Props) { className="game-action" variant="outline" disabled={busy || !canAct} - onClick={() => - act({ type: 'decision', event: offer.event, decline: true }) - } + onClick={() => { if (!busy && canAct) + act({ type: 'decision', event: offer.event, decline: true }); }} > Leave in reserves diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 397a089a..1377e4d7 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -20,6 +20,37 @@ open mode gates. Keep identified Basic-function gaps ahead of Advanced and wider interactions. Prototype labels and final acceptance gates remain. +**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 +entry. All six original Terror effects and both owner orders retain the victory +event; queued reactions finish before the battle suffix resumes. Storm-sector +entry remains unavailable conservatively. No spice/normal action or Spiritual +Advisor shipment source is added. HMS/Terror composition remains provisional. +[Source and contract](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md#victory-placement-and-arrival-reactions--27-october-2026). + +Frozen **12-file union97/97**, types/lint/build and twelve controlled runtime +journeys pass:39 GameTable SSR,12 actual chooser SSR and12 shared-adapter packets; +recorded battle/further actions91/32. These are controlled audit setups with +staged later armies, purses, original cards, token supply/order and a separate +contest retaining Battle phase. Actual battle/plan/call packets create each +victory; no reinforcement/entry/reaction receipt is installed. HMS uses its actual +Ix setup placement. The stateful chooser keeps its real initial target; no +React handler, browser/navigation/mobile, native acquisition or deployed claim. + +Feedback fixed the storm test's actual sector13, kept a real Karama for the +preventable BG flip, supplied the actual optional Sabotage gift card and finished +Ix placement after playing starts. Six old World Face Dance failures exposed an +invalid controlled source under existing Tleilax occupation immunity. Its repaired +A3/Guild2 visitor battle retains noncombatant nativeT2, both production guards, +3/4 replacement from Polar and the later A–T battle on decline. Failed v1 stays +preserved; v2 verifies the frozen source plus a final three-document overlay. +Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-caladan-arrival-proof-v2/`; +journeys/HTML: adjacent `20261027-caladan-arrival-runtime/run-v1/`. +Existing server HTTP200 without restart or saved-game/database operation. +Nested Sneak/Skills/Semuta compositions, full modes and final assurance/deployed +acceptance remain open; full AI refinement stays deferred. + **Imperial Ornithopter / Homeworld movement — 27 October. Prototyped / Partial.** Native Kaitain/Salusa transfers may now be first/second groups alongside ground movement or another distinct World group. Actual normal/Sardaukar pools and diff --git a/docs/HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md b/docs/HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md index e3304268..6e1e2798 100644 --- a/docs/HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md +++ b/docs/HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md @@ -66,15 +66,54 @@ Existing Intrusion and fighter/advisor arrival rules still apply. The source retains its existing payload shape, allowing an already-open exact child to continue without rewriting its parent or creating another reinforcement. -## Unresolved ordering and entry combinations +## Victory placement and arrival reactions — 27 October 2026 The selected Face Dance order above supersedes the earlier decline-only guard. -Storm-sector placement, Hidden Mobile Stronghold classification, and otherwise -relevant Intrusion/Terror reactions remain explicit source boundaries. -Destination guards use public physical presence and token placement, never a -secret Terror identity. Low Grumman's entering-force minimum still determines -whether its Terror reaction would be relevant to the one arriving counter. +The original Caladan face supplies a separate one-counter addition, not an +ordinary shipment, revival or Force Movement. Under standing recommended-option +authority, provisionally let this specific grant add to an already-surviving +victorious army inside HMS. E1p9/supplied Advanced physical34/printed32 bans other +factions' direct shipments and normally requires Force Movement through the +pointer territory; this specific card composition is not a publisher exception +example or a general relaxation of those routes. The sheltered interior0 does +not inherit the pointer's storm sector. Normal occupancy still applies. + +Supplied physical10/printed8 forbids ordinary shipment/movement into storm. +With no retrieved Caladan exception, retain that destination-sector prohibition +conservatively. This is an explicit composition, not a quoted Caladan storm rule. + +Advanced Intrusion says another faction **enters** (supplied physical28/printed26). +Place the counter once, offer the relevant BG fighter/advisor decision, then +process the original Ambassador/Terror arrival. Caladan itself creates no free +Spiritual Advisor shipment opportunity. Homeworlds remain separate from Arrakis +territories. Terror's ships/moves trigger (physical35/printed33) requires a +provisional classification of this addition; its native token effects and +original public one-counter minimum remain unchanged. The source lane found no +specific publisher/designer combined-effect ruling. Native Homeworld powers +retain E3p10 Karama immunity; canceling an independent BG reaction does not +cancel or refund the reinforcement. + +The original victory event binds queued entry, Intrusion and the signed Terror +receipt. Original Ambassador/Terror overlap uses storm order, and an Ambassador +opened second retains the same victory history. The reinforcement waits for +its arrival queue/overlap before resuming battle cleanup. Original ordinary +receipt/signature bytes remain unchanged when the new event field is absent. + +Frozen twelve-file97/97, types/lint/build and twelve controlled engine/GameTable +SSR programmes pass. Fifteen new engine cases and four chooser cases accompany +related native/Face Dance/Ambassador/discard regressions. Runtime records39 table +and12 chooser renders,12 shared-adapter one-force packets and91/32 recorded +battle/further actions. Controlled setup/positions/cards/token order are explicit; +actual battles create every original grant. A separate controlled contest prevents +unrelated Collection income from obscuring arrival outcomes. No stateful React +handler, browser/navigation/mobile, native acquisition or deployed proof is claimed. +The first failed batch's new fixtures were repaired; the older native-World +Face Dance source correction is explained below without weakening production. +Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-caladan-arrival-proof-v2/`; +journeys: adjacent `20261027-caladan-arrival-runtime/run-v1/`. +Nested Sneak/Skill/Semuta compositions, complete rules and final assurance/browser/ +deployed acceptance remain open. No saved-room/database/restart operation. The [integration audit](HOMEWORLD_VICTORY_REINFORCEMENT_INTEGRATION.md) maps battle cleanup and persistence. The @@ -88,8 +127,15 @@ Frozen15-file189/189, types/lint/build and sixteen engine/GameTable SSR journeys pass. Eight genuine public Basic59/Advanced124-action setup/shipment/battle sources keep all47 Treachery and16 original Traitor/Face Dancer identities; all give/leave and later reveal/decline combinations use the actual10/11 cap. -Six separately controlled native-Homeworld sources prove3/4 replacement and -Caladan9 return with existing occupation-profile bounds intact. Two controlled +The historical six controlled native-World sources used A-versus-native-T +combat; current occupied-Tleilax immunity now prohibits their positive reveal. +The 27 October corrected source instead stages A3/Guild2 visitors with native +T2 as a third noncombatant. Guild takes the losses; A never earns sole Tleilax +occupation, and the native-only Face Dance restriction stays intact. Accepted +replacement takes3/4 original Polar counters, adds them to native T2 and returns +A to Caladan9. Decline retains the later A–T battle; original-event completion +does not claim that battle finished. These remain controlled fixture sources, +not native invasion or acquisition journeys. Two controlled Ambassador journeys prove both legal accompaniment after Intrusion and automatic Polar conversion, preserving the original victory and leaving the Fremen token untouched by advisors. Controlled seams are not original public acquisition proof. diff --git a/docs/HOMEWORLD_VICTORY_REINFORCEMENT_RUNTIME.md b/docs/HOMEWORLD_VICTORY_REINFORCEMENT_RUNTIME.md index 3f6c2da8..6d98fdb7 100644 --- a/docs/HOMEWORLD_VICTORY_REINFORCEMENT_RUNTIME.md +++ b/docs/HOMEWORLD_VICTORY_REINFORCEMENT_RUNTIME.md @@ -1,7 +1,8 @@ # Caladan victory reinforcement runtime The 10 September checkpoint below is historical. The [26 October before-Face-Dance composition](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md#reinforcement-before-face-dance--26-october-2026) -supersedes its decline-only overlap. Use [current status](CURRENT_STATUS.md) +supersedes its decline-only overlap; the [27 October arrival contract](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md#victory-placement-and-arrival-reactions--27-october-2026) +supersedes the HMS/Intrusion/Terror destination guards. Use [current status](CURRENT_STATUS.md) for present preview admission and proof; this old checkpoint does not certify it. Development checkpoint, 10 September 2026. This implements the bounded behavior diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index efce2607..f8f9aef2 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,25 @@ # Dune implementation status +## 27 October 2026 — Caladan victory arrival + +One actual reserve counter now reaches a won HMS provisionally and resolves +Advanced Intrusion before original Ambassador/Terror reactions. The original +victory event survives both owner orders and all six direct Terror effects; +queued entry completes before battle cleanup resumes. Storm stays unavailable +conservatively, with no ordinary action, fee or Spiritual shipment source. + +Frozen12-file97/97, types/lint/build and twelve controlled runtime programmes +pass (39 table/12 chooser SSR,12 shared packets;91/32 recorded battle/further +actions). Actual battles create the grants from explicitly staged sources; +stateful React handlers/browser/deployed journeys are unclaimed. Six old World +Face Dance failures were corrected by preserving nativeT2 while A/Guild visitors +fight, retaining both printed production guards. Failed v1 remains; v2 binds the +frozen source and final three-document overlay. No saved-game/database/restart +operation. [Current checkpoint](CURRENT_STATUS.md#current-checkpoint-and-work) +and [contract/proof](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md#victory-placement-and-arrival-reactions--27-october-2026). +Full modes, nested compositions, final assurance and deployed acceptance remain +open; this does not certify the full goal. + ## 27 October 2026 — Imperial Ornithopter / Homeworld movement **Imperial Ornithopter / Homeworld movement — 27 October. Prototyped / Partial.** diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 5fb8a877..18a68b9a 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,19 @@ # Rule decision index +## Caladan victory placement and entry — 27 October 2026 + +Compose the original Caladan high face's one-counter addition with Advanced +Intrusion's "enters" trigger (supplied physical28/printed26), before the original +Ambassador/Terror reactions. It spends no ordinary action or spice and grants +no Spiritual Advisor shipment source. Under standing recommended-option +authority, HMS placement is a provisional specific-card exception to E1p9/ +supplied physical34's normal entry route; Terror's ships/moves trigger at +physical35 requires provisional classification. Keep the ordinary storm-sector +ban conservatively (physical10); sheltered HMS interior0 remains separate from +its pointer. Retain population, survivors, occupancy and Homeworld Karama +immunity. No specific combined publisher ruling was found. +[Contract](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md#victory-placement-and-arrival-reactions--27-october-2026). + ## Physical Ornithopter and Imperial Homeworld movement — 27 October 2026 E3printed10/supplied Advanced physical22/printed20 grant Emperor native Force diff --git a/game/arrival-overlap.ts b/game/arrival-overlap.ts index b055c175..19a95274 100644 --- a/game/arrival-overlap.ts +++ b/game/arrival-overlap.ts @@ -1,4 +1,5 @@ import type { Game } from './engine'; +import { caladanOwnsTerrorEntry } from './caladan-arrival'; import { currentHomeworldRevivalArrival } from './homeworld-revival-return'; import { validateRevivalArrivalSource, revivalOwnsTerrorEntry, validRevivalEntryParent, revivalEntryParentTuple, type RevivalEntryParent } from './revival-terror-entry'; @@ -6,7 +7,8 @@ import { validateRevivalArrivalSource, revivalOwnsTerrorEntry, validRevivalEntry type Overlap = NonNullable & { revivalArrival?: RevivalEntryParent }; type TerrorEntry = NonNullable & { revivalArrival?: RevivalEntryParent }; type AmbassadorEntry = NonNullable; -type DiscardContext = Parameters[0] & Pick; +type DiscardContext = Parameters[0] & Pick; function sameRevivalSource(left: RevivalEntryParent | undefined, right: RevivalEntryParent | undefined) { return validRevivalEntryParent(left) && validRevivalEntryParent(right) && @@ -19,7 +21,8 @@ export function arrivalOverlapSignature(record: Overlap): string { record.territory, record.sector, record.amount, record.elite, record.cause, record.resume, record.ecaz, record.moritani, record.first, record.ambassadorToken, record.terrorTokens, - ...(record.revivalArrival === undefined ? [] : [revivalEntryParentTuple(record.revivalArrival)])]); + ...(record.revivalArrival === undefined ? [] : [revivalEntryParentTuple(record.revivalArrival)]), + ...(record.victoryEvent === undefined ? [] : [['caladanVictory', record.victoryEvent]])]); } /** A fresh discard belongs to the active owner of an ordinary or bound revived entry. @@ -34,6 +37,8 @@ function ordinaryDiscardOverlap(g: DiscardContext, owner: Overlap['first']): Ove try { validateRevivalArrivalSource(g, overlap.revivalArrival, overlap); } catch { return null; } + } else if (overlap.cause === 'caladanReinforcement' || overlap.victoryEvent !== undefined) { + if (!caladanOwnsTerrorEntry(g, overlap)) return null; } else if (g.phase !== 5 || !['shipment', 'movement'].includes(overlap.cause)) return null; const ecaz = g.order.indexOf(overlap.ecaz), moritani = g.order.indexOf(overlap.moritani); @@ -54,6 +59,7 @@ export function overlapOwnsAmbassadorDiscard(g: DiscardContext, entry: Ambassado 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.revivalArrival === undefined || entry.revivalEvent === overlap.revivalArrival.event && !!arrival && sameRevivalSource(overlap.revivalArrival, { event: overlap.revivalArrival.event, ...(arrival.spiritual === undefined ? {} : { spiritual: arrival.spiritual }), @@ -67,6 +73,7 @@ export function overlapOwnsTerrorDiscard(g: DiscardContext, entry: TerrorEntry, entry.territory === overlap.territory && entry.sector === overlap.sector && entry.amount === overlap.amount && entry.elite === overlap.elite && entry.cause === overlap.cause && entry.turn === overlap.turn && entry.phase === overlap.phase && + (overlap.victoryEvent === undefined || entry.victoryEvent === overlap.victoryEvent) && (overlap.revivalArrival === undefined || sameRevivalSource(overlap.revivalArrival, entry.revivalArrival) && revivalOwnsTerrorEntry(g, entry)); } diff --git a/game/caladan-arrival.ts b/game/caladan-arrival.ts new file mode 100644 index 00000000..b42e1032 --- /dev/null +++ b/game/caladan-arrival.ts @@ -0,0 +1,37 @@ +import type { Game } from './engine'; +import { location, validLocation } from './board'; +import { HomeworldCustodyError } from './homeworld-custody'; +import { validateHomeworldVictoryReturn } from './homeworld-victory-return'; + +export type CaladanArrivalContext = Pick; +export type CaladanArrivalDetails = { + victoryEvent?: string; + entrant: string; + territory: string; + sector: number; + amount: number; + elite: number; + cause: string; +}; + +/** Validate the committed original addition, not the army after a reaction. + * Atomics, alliances and other accepted effects may already have changed it. */ +export function validateCaladanArrivalSource(g: CaladanArrivalContext, entry: CaladanArrivalDetails): void { + validateHomeworldVictoryReturn(g); + const frame = g.homeworldVictoryReinforcement; + if (g.status !== 'playing' || g.phase !== 6 || !frame || frame.stage !== 'arrival' || + frame.turn !== g.turn || frame.phase !== g.phase || entry.victoryEvent !== frame.event || + entry.entrant !== frame.player || entry.cause !== 'caladanReinforcement' || + entry.amount !== 1 || entry.elite !== 0 || !validLocation(entry.territory, entry.sector) || + frame.destination !== location(entry.territory, entry.sector)) + throw new HomeworldCustodyError('The Caladan arrival has lost its original committed victory reinforcement.'); +} + +export function caladanOwnsTerrorEntry(g: CaladanArrivalContext, entry: CaladanArrivalDetails): boolean { + try { validateCaladanArrivalSource(g, entry); return true; } + catch (error) { + if (error instanceof HomeworldCustodyError) return false; + throw error; + } +} diff --git a/game/engine.ts b/game/engine.ts index ba44678f..d147c101 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -191,6 +191,7 @@ 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 { homeworldArrivalSignature, appendHomeworldArrivalAmbassador, completeHomeworldArrivalAmbassador, validateHomeworldArrival } from './homeworld-arrival'; import { quoteHomeworldRevivalDeployment, @@ -1109,6 +1110,7 @@ export type Decision = } | { kind: 'intrusion'; + victoryEvent?: string; player: string; territory: string; wormRide?: boolean; @@ -1193,6 +1195,7 @@ export type ResponseWindow = { advisorGatherMove?: { turn: number; move: number; order: MovementOrder }; discoveryEntry?: DiscoveryEntryArrivalChild; advisorAmbassadorEvent?: string; + advisorVictoryEvent?: string; advisorRevivalEvent?: string; advisorRevivalTerror?: RevivalEntryParent['terror']; advisorRemaining?: string[]; @@ -2046,6 +2049,7 @@ export type Game = { dukeVidal?: DukeState; dukeAcquisitionTurn?: number; pendingTerrorEntry?: { + victoryEvent?: string; revivalArrival?: RevivalEntryParent; /** Original public arrival, absent only in legacy saves. */ entrySignature?: string; @@ -2093,6 +2097,7 @@ export type Game = { /** Committed entries whose optional reactions must wait for the arrival's own * pending interaction (payment, advisor choice, storm protection). */ pendingArrivalReaction?: { + victoryEvent?: string; entrant: string; territory: string; sector: number; @@ -2108,6 +2113,7 @@ export type Game = { }[] | null; /** One committed entry with two independently optional expansion reactions. */ pendingArrivalOverlap?: { + victoryEvent?: string; revivalArrival?: RevivalEntryParent; event: string; signature: string; @@ -8265,6 +8271,7 @@ function treacheryDiscardIntegrity(g: Game) { 'advisor', 'wormRide', 'homeworldRevival', + 'caladanReinforcement', 'ambassador', ].includes(entry.cause) && ['none', 'wormRide', 'ambassador'].includes(entry.resume) && @@ -8311,7 +8318,7 @@ function treacheryDiscardIntegrity(g: Game) { (entry.resume === 'wormRide' || overlapOwnsWormTerror(g,entry)) && g.phase === 1 && g.players.some(player => player.id === entry.entrant && player.faction === 'fremen') : entry.resume === 'none' && (entry.cause !== 'homeworldRevival' || !!entry.revivalArrival)) && - ['shipment', 'movement', 'guildTransport', 'advisor', 'wormRide', 'homeworldRevival', 'ambassador'].includes(entry.cause) && + ['shipment', 'movement', 'guildTransport', 'advisor', 'wormRide', 'homeworldRevival', 'caladanReinforcement', 'ambassador'].includes(entry.cause) && entry.entrant !== owner?.id && entry.territory === aftermath.territory && terrorEntryLocationAllowed(g,entry) && validGameLocation(g, entry.territory, entry.sector) && token?.kind === 'atomics' && token.status === 'removed' && @@ -21762,6 +21769,8 @@ function arrivalOverlapIntegrity(g: Game) { (continuation?.kind === 'terrorDiscard' || continuation?.kind === 'terrorAtomics' ? continuation.entry : null); if (record.revivalArrival) homeworldRule(() => validateRevivalArrivalSource(g, record.revivalArrival!, record)); + if (record.victoryEvent !== undefined || record.cause === 'caladanReinforcement') + homeworldRule(() => validateCaladanArrivalSource(g, record)); const active = record.stage === 'first' ? record.first : record.first === 'ambassador' ? 'terror' : 'ambassador'; requireRule(g.status === 'playing' && arrivalOverlapModeSupported(g, record.revivalArrival) && @@ -21838,6 +21847,12 @@ function openAmbassadorEntry( g.pendingAmbassador.revivalEvent = frame.event; homeworldRule(() => appendHomeworldArrivalAmbassador(frame, g.pendingAmbassador!, undefined, revivalArrival.spiritual, revivalArrival.terror)); + } else if (arrivalCause === 'caladanReinforcement') { + const frame = g.homeworldVictoryReinforcement!; + homeworldRule(() => validateCaladanArrivalSource(g, {victoryEvent:frame?.event, entrant:entrant.id, + territory:to, sector, amount:1, elite:0, cause:arrivalCause})); + g.pendingAmbassador.victoryEvent = frame.event; + appendHomeworldArrivalAmbassador(frame, g.pendingAmbassador); } log(g, `${owner.name} may trigger the ${faction(token.effect).name} Ambassador because ${entrant.name} entered ${territory(to).name}. The entrant’s remaining actions wait for this opportunity.`); return true; @@ -21864,6 +21879,9 @@ function openTerritoryEntry( if (revivalArrival) homeworldRule(() => validateRevivalArrivalSource(g, revivalArrival, {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})); // The arrival's own interaction (guild payment, advisor choice, storm // protection) settles first; the entry then opens from the automatic pass. if (g.response || g.decision || g.pendingTerrorEntry || g.pendingAmbassador || @@ -21871,6 +21889,7 @@ function openTerritoryEntry( (g.pendingArrivalReaction ??= []).push({ entrant: entrant.id, territory: to, sector, amount, elite, cause, resume, turn: g.turn, phase: g.phase, + ...(victoryEvent === undefined ? {} : {victoryEvent}), ...(revivalArrival ? { revivalArrival } : {}), }); return true; @@ -21924,6 +21943,7 @@ function openTerritoryEntry( entrant: entrant.id, territory: to, sector, amount, elite, cause, resume, ecaz: owner.id, moritani: moritani.id, first, stage: 'first', ambassadorToken: token.id, terrorTokens: terrorTokens.map(t => t.id), + ...(victoryEvent === undefined ? {} : {victoryEvent}), ...(revivalArrival ? {revivalArrival:structuredClone(revivalArrival)} : {}), }; overlap.signature = arrivalOverlapSignature(overlap); @@ -24210,6 +24230,8 @@ function terrorEntryIntegrity(g: Game) { if (!entry) continue; homeworldRule(() => validateTerrorEntrySignature(entry)); if (entry.revivalArrival) homeworldRule(() => validateRevivalTerrorEntry(g, entry)); + if (entry.victoryEvent !== undefined || entry.cause === 'caladanReinforcement') + homeworldRule(() => validateCaladanArrivalSource(g, entry)); if (entry.entrySignature === undefined) continue; requireRule(entry.turn === g.turn && entry.phase === g.phase && g.players.some((p) => p.id === entry.entrant && p.faction !== 'moritani') && @@ -24288,6 +24310,7 @@ function openTerrorEntry( stage: tokens.length > 1 ? 'select' : 'offer', resume, ...(ambassadorEvent ? { ambassadorEvent } : {}), + ...(cause === 'caladanReinforcement' ? {victoryEvent:g.homeworldVictoryReinforcement?.event} : {}), ...(revivalArrival ? {revivalArrival:structuredClone(revivalArrival)} : {}), }; if (!TERROR_STRONGHOLDS.includes(to)) { @@ -26472,6 +26495,7 @@ function finishAdvisorReaction( advisorResume?: ResponseWindow['advisorResume']; discoveryEntry?: DiscoveryEntryArrivalChild; advisorAmbassadorEvent?: string; + advisorVictoryEvent?: string; advisorRevivalEvent?: string; advisorRevivalTerror?: RevivalEntryParent['terror']; advisorRemaining?: string[]; @@ -26500,6 +26524,7 @@ function intrusion( wormRide?: boolean; discoveryEntry?: DiscoveryEntryArrivalChild; ambassadorEvent?: string; + victoryEvent?: string; revivalEvent?: string; revivalTerror?: RevivalEntryParent['terror']; followup?: { shipment: string; destination: string; revivalEvent?: string; revivalTerror?: RevivalEntryParent['terror'] }; @@ -32170,6 +32195,26 @@ function homeworldVictoryReturnIntegrity(g: Game) { g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume, continuation && 'resume' in continuation ? continuation.resume : null]; 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)); + const intrusionChoices = contexts.flatMap(context => context?.decision?.kind === 'intrusion' && + context.decision.victoryEvent !== undefined ? [context.decision] : []); + const intrusionResponses = [...contexts.map(context => context?.response), + g.pendingKarama?.use.kind === 'cancel' ? g.pendingKarama.use.response : undefined] + .filter((response): response is ResponseWindow => !!response && response.advisorVictoryEvent !== undefined); + for (const choice of intrusionChoices) { + requireRule(choice.player === byFaction(g, 'beneGesserit')?.id, 'The Caladan Intrusion belongs to Bene Gesserit.'); + homeworldRule(() => validateCaladanArrivalSource(g, {victoryEvent:choice.victoryEvent, + entrant:frame?.player ?? '', territory:choice.territory, sector:splitLocation(frame?.destination ?? '').sector, + amount:1, elite:0, cause:'caladanReinforcement'})); + } + for (const response of intrusionResponses) { + requireRule(response.kind === 'advisorFlip' && response.advisors === true && + response.owner === byFaction(g, 'beneGesserit')?.id, 'The Caladan Intrusion has lost its advisor response.'); + homeworldRule(() => validateCaladanArrivalSource(g, {victoryEvent:response.advisorVictoryEvent, + entrant:frame?.player ?? '', territory:response.location ?? '', sector:splitLocation(frame?.destination ?? '').sector, + amount:1, elite:0, cause:'caladanReinforcement'})); + } requireRule(decisions.every((decision) => frame?.stage === 'choice' && decision.player === frame.player && decision.event === frame.event), 'The Caladan choice has lost its original victory.'); if (frame?.stage === 'choice') @@ -32255,11 +32300,8 @@ function decideHomeworldVictoryReturn(g: Game, p: Player, action: Action) { stampHomeworldVictoryStage(g); if (quote.selected.territory) { place(p, quote.selected.territory, quote.selected.sector!, 1); + intrusion(g, p, quote.selected.territory, {victoryEvent:frame.event}); openTerritoryEntry(g, p, quote.selected.territory, quote.selected.sector!, 1, 0, 'caladanReinforcement'); - if (g.pendingAmbassador) { - g.pendingAmbassador.victoryEvent = frame.event; - appendHomeworldArrivalAmbassador(frame, g.pendingAmbassador); - } } log(g, `${p.name} added one force from Caladan reserves to ${quote.selected.name} after winning the battle. The force joins an existing surviving army; no spice or shipment allowance was spent.`, { faction: p.faction, name: 'Caladan victory reinforcement' }); @@ -32268,7 +32310,8 @@ function resumeHomeworldVictoryReturn(g: Game) { const frame = g.homeworldVictoryReinforcement; if (frame?.stage !== 'arrival' || g.pendingTreacheryDiscard || g.pendingNullentropy || g.truthtrance || g.pendingExchange || g.pendingRicheseGift || g.pendingAmbassador || - g.pendingTerrorEntry || g.pendingKarama || g.decision || g.response) return; + g.pendingTerrorEntry || g.pendingArrivalOverlap || g.pendingArrivalReaction?.length || + g.pendingKarama || g.decision || g.response) return; completeHomeworldVictoryReturn(g); finishBattle(g); } @@ -38903,6 +38946,8 @@ function settleAutomaticContinuations(g: Game) { !g.pendingTerrorEntry && !g.pendingAmbassador && !g.pendingArrivalOverlap && !g.pendingSpiritualAdvisor) { const deferred = g.pendingArrivalReaction.shift()!; if (!g.pendingArrivalReaction.length) g.pendingArrivalReaction = null; + if (deferred.victoryEvent !== undefined || deferred.cause === 'caladanReinforcement') + 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.revivalSneakAdvisor) { @@ -40872,6 +40917,7 @@ function applyActionInner( ? ('wormRide' as const) : undefined, advisorAmbassadorEvent: decision.ambassadorEvent, + advisorVictoryEvent: decision.victoryEvent, advisorRevivalEvent: decision.revivalEvent, advisorRevivalTerror: decision.revivalTerror, discoveryEntry: decision.discoveryEntry, diff --git a/game/homeworld-victory-reinforcement.ts b/game/homeworld-victory-reinforcement.ts index affb6137..56919e96 100644 --- a/game/homeworld-victory-reinforcement.ts +++ b/game/homeworld-victory-reinforcement.ts @@ -2,11 +2,9 @@ import type { Game } from './engine'; import { gameTerritories, location, - MOBILE_STRONGHOLD, splitLocation, validLocation, } from './board'; -import { fighterCount } from './advisors'; import { territoryEntryBlock } from './occupancy'; import { homeworldContext } from './homeworld-game'; import { @@ -17,7 +15,6 @@ import { import { homeworldPopulations } from './homeworld-population'; import { HOMEWORLD_CARDS } from './homeworld-cards'; import { quoteNativeReserveWithdrawal } from './homeworld-native-reserves'; -import { lowGrummanRevealBlock } from './homeworld-collection'; export type HomeworldVictoryReinforcementContext = Pick< Game, @@ -168,31 +165,12 @@ export function quoteHomeworldVictoryReinforcement( } if (!Number.isSafeInteger(g.storm) || g.storm < 1 || g.storm > 18) invalid('Caladan reinforcement requires the current storm sector.'); - const bg = g.players.find((seat) => seat.faction === 'beneGesserit'); - const moritani = g.players.find((seat) => seat.faction === 'moritani'); quote.destinations = board!.sectors.map((sector) => { let blocked: string | null = null; - if (board!.id === MOBILE_STRONGHOLD) + if (sector === g.storm) blocked = - 'Caladan reinforcement in the Hidden Mobile Stronghold awaits its entry-classification ruling.'; - if (!blocked && sector === g.storm) - blocked = - 'Caladan reinforcement into a sector in storm awaits its placement-permission ruling.'; + 'You cannot add the Caladan reinforcement to a sector in storm.'; blocked ??= territoryEntryBlock(g.players, owner.id, board!.id); - if (!blocked && g.advanced && bg && fighterCount(bg, board!.id) > 0) - blocked = - 'Intrusion after Caladan reinforcement awaits an entry-classification ruling.'; - if ( - !blocked && - moritani && - owner.id !== moritani.ally && - !lowGrummanRevealBlock(g, moritani.id, 1) && - g.moritaniTerror?.tokens.some( - (token) => token.status === 'placed' && token.location === board!.id, - ) - ) - blocked = - 'Terror after Caladan reinforcement awaits an entry-classification ruling.'; return { id: location(board!.id, sector), name: `${board!.name}${sector ? ` · sector ${sector}` : ''}`, diff --git a/game/reference.ts b/game/reference.ts index a76e0620..d2f6381a 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -5410,7 +5410,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Low Caladan prevents the phase-opening Spice Blow peek. Low Wallach IX prevents free Spiritual Advisors, while high Wallach IX allows one or two to Polar Sink after another faction’s qualifying shipment. Accompanying elsewhere still uses one. Low Ix prevents moving the mobile stronghold; low Richese prevents moving a No-Field token. Physical-force movement and ordinary paid shipments retain their separate rules.', 'Newly revived eligible placement permits Advanced BG Intrusion when entering fighters. Under the provisional SEND composition, Tleilax free forces sent from off-planet to Arrakis also earn ordinary Spiritual accompaniment; Southern on-planet placement does not. The original group stays placed through independent BG counters, and original revival income waits until arrival finishes. No fee, ordinary allowance, Guild stop or Revival-phase industry is added. Phase-four Spiritual sources retain the original return or actual Guild child. Terror-entry continuations remain unfinished.', 'After a supported Atreides victory, Caladan with at least six native reserves may add one reserve force to the battle location if an Atreides force survives there. The owned choice follows ordinary battle cleanup and offers an eligible sector or the original foreign Homeworld. This spends no spice or shipment allowance. Leaving the force in reserves is optional; a battle on Caladan itself cannot create an extra counter from reserves already there.', - 'Caladan reinforcement keeps the original battle identity through refresh and supported Ambassador reactions, so the same victory cannot add a second force. Under the provisional order, reinforcement joins the original winning army before any later Face Dance; its surviving counter counts in the current replacement limit and returns with that army. A return that raises Caladan does not create a second grant. Storm, mobile stronghold and unresolved entry restrictions remain.', + 'Caladan reinforcement keeps the original battle identity through refresh and supported Ambassador reactions, so the same victory cannot add a second force. Under the provisional order, reinforcement joins the original winning army before any later Face Dance; its surviving counter counts in the current replacement limit and returns with that army. A return that raises Caladan does not create a second grant. Provisional HMS placement adds to the surviving won interior without an ordinary shipment. Advanced Intrusion resolves before the original Ambassador/Terror entry; Terror classification is provisional. Storm-sector entry stays unavailable, and nested module interactions remain open.', 'At low population, Tupile lets CHOAM learn an opposing faction’s current spice and either its weapon count or defense count, once per faction for the entire game. CHOAM must have forces on that faction’s Homeworld, or that faction must have forces on Tupile. Either Emperor Homeworld supplies the same single entitlement. The answer is recorded privately as a historical observation and does not update when that faction’s hand or spice changes.', 'Choose weapons or defenses in the private Tupile panel at a settled action boundary. The server answers immediately without an opponent confirmation, and does not expose individual cards. Count physical held Treachery cards by their default category: Weirding Way is a weapon, Chemistry is a defense, while Worthless cards and leader substitutes count as neither. Selecting the other category later cannot renew a faction’s used entitlement.', 'Occupation removes Tupile’s low advantage. New Homeworld games record qualifying foreign presence from setup, including brief sole occupation during ordered battle losses and presence at turn boundaries. Earlier saves without that history, or histories with a Tupile qualifier whose continuing entitlement is unresolved, cannot make a new intelligence request. Already learned observations remain readable. Recorded qualification does not itself award occupied income or settle its expiry.', @@ -5452,6 +5452,12 @@ export const RULE_TOPICS: RuleTopic[] = [ 'components/reinforcements-sources.tsx', 'tests/nexus-world-independent-cards.test.ts', 'tests/nexus-world-independent-battles.test.ts', + 'game/caladan-arrival.ts', + 'game/homeworld-victory-reinforcement.ts', + 'components/caladan-reinforcement.tsx', + 'tests/caladan-arrival-engine.test.ts', + 'tests/caladan-arrival-controls.test.tsx', + 'docs/HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md', 'game/homeworld-cards.ts', 'game/richese-homeworld-shipment.ts', 'game/homeworld-payment-options.ts', diff --git a/game/terror-entry-receipt.ts b/game/terror-entry-receipt.ts index 095020e6..9be3e20a 100644 --- a/game/terror-entry-receipt.ts +++ b/game/terror-entry-receipt.ts @@ -23,6 +23,7 @@ export function terrorEntrySignature(entry: TerrorEntry): string { entry.ambassadorEvent ?? null, ...(entry.nexusPlacements ? [entry.nexusPlacements] : []), ...(entry.revivalArrival === undefined ? [] : [revivalEntryParentTuple(entry.revivalArrival)]), + ...(entry.victoryEvent === undefined ? [] : [['caladanVictory', entry.victoryEvent]]), ]); } @@ -31,13 +32,14 @@ export function terrorSelectionSignature(entry: TerrorEntry): string { entry.territory, entry.sector, entry.amount, entry.elite, entry.cause, entry.turn, entry.phase, entry.resume, entry.ambassadorEvent ?? null, ...(entry.nexusPlacements ? [entry.nexusPlacements] : []), - ...(entry.revivalArrival === undefined ? [] : [revivalEntryParentTuple(entry.revivalArrival)])]); + ...(entry.revivalArrival === undefined ? [] : [revivalEntryParentTuple(entry.revivalArrival)]), + ...(entry.victoryEvent === undefined ? [] : [['caladanVictory', entry.victoryEvent]])]); } /** 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.stage !== 'select') return; + 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 ( typeof entry.entrySignature !== 'string' || typeof entry.token !== 'string' || @@ -72,6 +74,8 @@ export function validateTerrorEntrySignature(entry: TerrorEntry): void { entry.phase < 0 || entry.phase > 8 || (entry.revivalArrival !== undefined && !validRevivalEntryParent(entry.revivalArrival)) || + (entry.victoryEvent !== undefined && (typeof entry.victoryEvent !== 'string' || !entry.victoryEvent || + entry.cause !== 'caladanReinforcement' || entry.phase !== 6 || entry.revivalArrival !== undefined)) || (![ 'shipment', 'movement', @@ -79,7 +83,8 @@ export function validateTerrorEntrySignature(entry: TerrorEntry): void { 'advisor', 'wormRide', 'ambassador', - ].includes(entry.cause) && !(entry.cause === 'homeworldRevival' && entry.revivalArrival !== undefined)) || + ].includes(entry.cause) && !(entry.cause === 'homeworldRevival' && entry.revivalArrival !== undefined) && + !(entry.cause === 'caladanReinforcement' && entry.victoryEvent !== undefined)) || !['none', 'wormRide', 'ambassador'].includes(entry.resume) || (entry.ambassadorEvent !== undefined && (typeof entry.ambassadorEvent !== 'string' || !entry.ambassadorEvent)) || diff --git a/tests/caladan-arrival-controls.test.tsx b/tests/caladan-arrival-controls.test.tsx new file mode 100644 index 00000000..fbea8fc5 --- /dev/null +++ b/tests/caladan-arrival-controls.test.tsx @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { applyAction, viewGame, type Game } from '../game/engine'; +import { CaladanReinforcement } from '../components/caladan-reinforcement'; +import { caladanReinforcementCanAct, caladanReinforcementChoice } from '../game/caladan-reinforcement-options'; +import { + caladanVictoryFixture, prepareVictoryBattle, commitVictoryPlans, finishVictoryCalls, + victoryPlayer, +} from './fixture-caladan-victory'; + +// Existing controlled victory source: genuine Homeworld setup/deal, staged +// later phase/armies/wallets and storm position, then real battle packets yield +// the choice. This is not original-game progression or an HMS entry programme. +// SSR and shared-adapter checks do not operate the stateful React chooser. +const sources = new Map(); +function choiceGame(advanced = true): Game { + let source = sources.get(advanced); + if (!source) { + const initial = caladanVictoryFixture({ advanced }); + initial.storm = 13; + source = finishVictoryCalls(commitVictoryPlans(prepareVictoryBattle(initial))); + assert.equal(source.decision?.kind, 'caladanReinforcement'); + assert.equal(source.lastBattleContext?.winner, 'a'); + sources.set(advanced, source); + } + return structuredClone(source); +} +function html(game: Game, owner = 'a', busy = false) { + return renderToStaticMarkup(createElement(CaladanReinforcement, { game: viewGame(game, owner), busy, + act() { assert.fail('Rendering the chooser must not submit a decision.'); } })); +} + +void test('the engine-owned Caladan choice keeps its default target and original exact one-force adapter packet', () => { + const game = choiceGame(), view = viewGame(game, 'a'), offer = view.caladanReinforcement; + assert.ok(offer); + const target = offer.destinations.find(destination => !destination.blocked); assert.ok(target); + assert.ok(target.id.startsWith('hagga_basin:')); + const markup = html(game); + assert.ok(markup.includes(`