diff --git a/components/game-table.tsx b/components/game-table.tsx index 347bad40..be51b584 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -2784,7 +2784,7 @@ export function GameTable({ : g .decision .kind === - 'wormProtection' + 'wormProtection' || g.decision.kind === 'nexusFremenProtection' ? 'Protection of Shai-Hulud' : g .decision @@ -3590,6 +3590,29 @@ export function GameTable({ ) : g.decision.kind === 'greatMakerVote' || g.decision.kind === 'greatMakerRide' ? ( + ) : g.decision.kind === 'nexusFremenProtection' ? ( +
+

+ Shai-Hulud has appeared in {combatName(g.nexusFremenProtection?.territory ?? '')}. + Threatened unallied seats with a held Nexus acknowledge in storm order + before any forces are devoured, regardless of the secret card. +

+ {g.nexusFremenProtection?.blocked ? ( +

{g.nexusFremenProtection.blocked}

+ ) : <> + {g.nexusFremenProtection?.canUse && <> +

Provisional policy: protect only your forces from this worm. + Spice and other factions remain exposed. This independent protection + has no additional Karama response and grants no worm ride.

+ {actionButton('Spend Nexus · Protect my forces', { + type: 'nexusFremenProtection', event: g.nexusFremenProtection.event, use: true, + })} + } + {g.nexusFremenProtection && actionButton('Continue without worm protection', { + type: 'nexusFremenProtection', event: g.nexusFremenProtection.event, use: false, + })} + } +
) : g.decision.kind === 'wormProtection' ? ( <>

diff --git a/components/nexus-cards.tsx b/components/nexus-cards.tsx index 29f312dd..21b9b9f7 100644 --- a/components/nexus-cards.tsx +++ b/components/nexus-cards.tsx @@ -121,7 +121,7 @@ export function NexusCards({ game, act, busy }: { game: GameView; act: (action:

Nexus cards

{offer.deckCount} in deck · {offer.discardCount} discarded · {Object.values(offer.held).filter((held) => held === true).length} held

-

Playable effects include Atreides inspections, Harkonnen exchanges, and Tleilaxu, Ixian, Bene Gesserit, Emperor, CHOAM, Moritani and bounded Ecaz Duke Cunning, plus Emperor Secret Ally’s bank-auction purchase and three extra free revivals, Fremen Secret Ally’s ordinary three free revivals, Richese Secret Ally reserve shipping and Guild Cunning’s second shipment or absent-Guild Secret Ally shipping. Use the controls shown at each effect’s timing. {game.nexusKullPreview ? 'CHOAM Nexus Kull is reactive: choose its fuel at the Karama opportunity.' : 'CHOAM Nexus Kull is unavailable outside its explicit preview.'} Moritani Cunning relocation and Grumman combinations, and other unfinished effects remain unavailable.

+

Connected controls include Atreides inspections, Harkonnen exchanges, native Cunning, bounded Ecaz Duke acquisition, Emperor bank purchases and extra revivals, Tleilaxu free-leader/paid-force revival, Fremen ordinary free revival or worm protection, Bene Gesserit borrowed Voice, and Richese/Guild shipment effects. Use only the controls offered at each effect’s timing; provisional interpretations are labelled there. {game.nexusKullPreview ? 'CHOAM Nexus Kull is reactive: choose its fuel at the Karama opportunity.' : 'CHOAM Nexus Kull is unavailable outside its explicit preview.'} Moritani Cunning enhances supply placement, not relocation. Unsupported combinations and complete public modes remain gated.

{game.nexusAtreides && offer.card === 'atreides' && (
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 869fcd1f..c695e663 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,23 @@ optional skill bands, combinations and public expansion starts remain open. Rules implementation continues; comprehensive assurance and administration remain deferred until all rules are implemented. +**Fremen Secret Ally worm protection — 14 October.** The recommended +independent-operation prototype now saves only the actual unallied holder's +groups before original devouring. Publicly possible threatened held-Nexus +seats acknowledge in frozen storm order, independent of hidden face; no +additional Karama provider or riding is invented. Native casualties/spice, +Great Maker vote and the next blow/Nexus remain original. Actual Basic/ +Advanced source and four legal profiles pass in the related9-file union86/86. +The390px table preserves Emperor1/reserves19/Tanks0/spice10, devours the BG +advisor to Tanks1, clears four Hagga Basin spice, spends one Nexus and accepts +the resumed Sihaya blow without overflow or browser errors. A storm-invalid +fixture source was corrected to a real safe shipment, not weakened engine +legality. Broader module/marker combinations and final acceptance stay open. +Final types/lint/build pass. Four actual Basic/Advanced normal/Great Maker +API runs preserve those costs, close the original vote where applicable and +reach Sihaya once. Owned QA host/tab/scaffolding removed; proofs outside Git. +See [the source-bound policy](NEXUS_FREMEN_RULES.md#recommended-independent-protection--14-october-2026). + **Bene Gesserit borrowed Voice — 14 October.** The recommended provisional policy is now connected: actual unallied absent-BG holder, attacker-first neutral continuation before Prescience, one spent-card declaration and the diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 69a95f17..c1a68f88 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,48 @@ # Dune implementation status +## 14 October 2026 — Absent-Fremen Nexus pre-devour protection + +Explicit recommended interpretation: independent printed protection, without +inventing an absent native Karama provider or a ride. The original public +threatened/unallied/held-Nexus membership determines one frozen storm-order +queue; private face determines only Use. Both passes and one physical-card +declaration precede all original devouring. Protect only that owner; original +other losses, spice removal, replacement blow and Nexus resume once. + +Disjoint public-source and native-proof slices were integrated by one owner. +Occurrence UUID/turn/territory/pile/index or summon parent, ordered cursor, +spent owner and current decision are bound before settlement. Public quotes +read no No-Field denomination. Existing protected-victim skipping retains +protected markers; unprotected native materialization stays unchanged. +Unrelated dynamic groups do not block the actual printed worm territory. +Completed receipts tolerate later legitimate physical-card reshuffling. +Original Great Maker worm protection now preserves its original vote and +delays the replacement until that vote finishes. + +The native fixture initially selected a storm-caught Cielago South sector; +its real shipment correctly failed. It now chooses original Hagga Basin12 +through actual paid shipment, with no storm/phase/force/hand rewrite. Setup, +natural worm/alliance/closing draw, free Advanced BG accompaniment, collection +and the next accepted worm are original producers; only conserved undealt +deck order and scoped scalar entropy are staged. Fixture-trace/copy assertions +were removed. Shared continuation helpers and exhaustive decision catalogs +now recognize the new protection and preceding borrowed Voice queues. + +Related9-file union86/86 and actual390px table pass. Neutral Continue leaves +all groups/spice intact; holder Use preserves Emperor1/reserves19/Tanks0/ +spice10, moves BG advisor1 to Tanks, removes four source spice and spends one +Nexus. No ride; original Accept spice blow accepts the resumed Sihaya card. +No overflow/errors. Four legal profiles execute own Use or neutral Pass. +Stale Nexus table prose was corrected to list the connected effects and +supply-only Moritani Cunning rather than claiming Grumman remained unavailable. +Final types/lint and build pass, retaining existing build warnings. Four +actual Basic/Advanced normal/Great Maker API runs preserve source4→0, +owner1/Tanks0/reserves19/spice10 and one discard; the Maker retains vote→ +complete before Sihaya, with no ride. Owned host/tab/scaffold removed; +source-bound phone/API receipts remain outside Git. +Broader module/marker continuations, comprehensive assurance, full modes, +calibration and deployed acceptance remain open. + ## 14 October 2026 — Bene Gesserit Secret Ally borrowed Voice Recommended interpretation: one Karama-cancellable named Voice advantage, diff --git a/docs/NEXUS_CARD_RULES.md b/docs/NEXUS_CARD_RULES.md index 535eb065..a2ffd4ed 100644 --- a/docs/NEXUS_CARD_RULES.md +++ b/docs/NEXUS_CARD_RULES.md @@ -2,6 +2,18 @@ Original source audit, 10 September 2026, with dated bounded runtime follow-ups below. This document establishes the printed component inventory, source protocol and implementation boundaries; none lifts expansion release gates. The ordinary worm-triggered alliance Nexus already in the engine is distinct from this optional card module. +## Recommended absent-Fremen protection — 14 October 2026 + +The printed Secret Ally protection now connects before original worm +destruction through a neutral storm-order queue of publicly possible +unallied threatened held-Nexus seats. Only the actual Fremen-card holder may +spend. It protects that owner's groups only; original other casualties, +spice removal, Great Maker vote and subsequent Nexus continue once. +Independent protection has no invented extra Karama provider/window under +an explicit provisional interpretation. No hidden-holder-only pause, revival +or ride is created. Native Fremen Cunning remains a different producer. +[Source, controls and changed-path proof](NEXUS_FREMEN_RULES.md#recommended-independent-protection--14-october-2026). + ## Recommended absent-BG borrowed Voice — 14 October 2026 The named-advantage recommendation now connects Secret Ally Voice through @@ -1365,6 +1377,7 @@ The [bounded Ixian Secret Ally replacement integration](NEXUS_IXIAN_REPLACEMENT_ - Duration is explicit for several panels but not all. Ixian Betrayal's scope over multiple auction lots, the optional continuation after canceling native Prescience, and exact multi-party cancellation priority require the corresponding existing faction timing contract. Do not make an entire-phase cancellation merely from the word “advantage.” - Emperor forced ally payment and purchase refunds retain their precise source-invoice requirements. Tleilaxu's combined revival now uses the [explicit recommended policy](NEXUS_TLEILAXU_RULES.md#recommended-combined-revival-prototype--14-october-2026): a mandatory free own leader/ordinary slot and fixed paid physical group sharing an expanded ordinary ceiling, with preserved cycles and typed caps. This is a provisional implementation, not a new publisher answer; the five counters are not free. - Bene Gesserit borrowed Voice now follows the [recommended cancellable named-advantage policy](NEXUS_BENE_GESSERIT_RULES.md#recommended-borrowed-voice-prototype--14-october-2026), with neutral pre-Prescience timing and original native command checks. The prototype does not answer generic hidden-reaction priority, fresh advisor/storm permission or broader named-special/module combinations. +- Fremen Secret Ally protection now follows the [recommended independent-operation policy](NEXUS_FREMEN_RULES.md#recommended-independent-protection--14-october-2026), using original threatened public membership and pre-devour timing. This does not resolve general reactive Betrayal priority or widen native Cunning, and broader revival/module accounting remains separate. - Richese's two-No-Field effect has a bounded native Cunning shipment follow-up, but wider marker/module compositions remain separate. Its [bounded Betrayal auction contract](NEXUS_RICHESE_RULES.md#bounded-betrayal-auction-contract--30-september-2026) connects public self-cache veto and other-buyer sale diversion, not normal-deck hidden-family self-purchases or private special-Karama acquisition. Ecaz's Duke override needs explicit restoration/end-turn handling across capture, death and Ghola records. These are material implementation tasks, not reasons to invent substitute effects. - A complete twelve-card reference/inventory and common lifecycle are substantial first work, but do not alone make all thirty-six panels playable. Keep the public module release gated until every face's supported timing, secrecy, custody, bots, UI, rejection, multiplayer CAS and saved continuation paths are verified. If development supports only Atreides' family first, other faces must remain accurately represented and explicitly unavailable in the audit mode; they must not silently act as blanks in a released module. diff --git a/docs/NEXUS_FREMEN_RULES.md b/docs/NEXUS_FREMEN_RULES.md index ef8d743a..26ab8fff 100644 --- a/docs/NEXUS_FREMEN_RULES.md +++ b/docs/NEXUS_FREMEN_RULES.md @@ -1,6 +1,6 @@ # Fremen Nexus: worm and revival contract -Primary-source and integration audit, 10 September 2026; narrow Secret Ally Revival and native Cunning prototypes integrated later. This document does not certify Fremen protection, combined-module Cunning or complete Fremen effects, and does not open a release gate. The [common Nexus rules](NEXUS_CARD_RULES.md) apply. No new user question was sent. +Primary-source and integration audit, 10 September 2026, with dated Secret Ally revival, protection and native Cunning prototypes. These bounded paths do not certify complete Fremen/Nexus effects or open a release gate. The [common Nexus rules](NEXUS_CARD_RULES.md) apply. ## Evidence @@ -114,16 +114,62 @@ fails before the software guard removal and passes afterward. ## Secret Ally protection -With Fremen absent, `wormSurvival` currently proceeds directly to destruction. The protection must be committed before that destruction; it cannot revive already devoured troops, prevent removal of spice, or grant a ride. Bind it to one worm occurrence and the holder's affected physical group, including the existing No-Field reveal/capped-materialization path. Other factions remain vulnerable. +With Fremen absent, protection must be committed before original destruction. +It saves only the holder's threatened groups at this accepted appearance, +not already-devoured troops, territory spice or other factions, and grants +no riding or native Fremen identity. -A pause offered only when the threatened player secretly holds this card would disclose possession. Do not add a holder-dependent automatic stop or an unsolicited confirmation for every victim. This intersects the existing unanswered reaction/automatic-pass policy. A saved public occurrence may support a future permitted response design, but it is not authorization to pick that design here. No source found gives the absent-faction card blanket Karama susceptibility; do not reuse a native Fremen cancellation response without establishing its classification. +### Recommended independent protection — 14 October 2026 + +Choose-recommended-option priority adopts the printed protection as an +independent Secret Ally operation, with **no invented extra Karama response** +or absent native provider. This contrasts with explicitly borrowed named +Voice. It is a visible provisional interpretation, not publisher clarification +or blanket Nexus immunity. + +Before devouring, every publicly possible unallied threatened seat holding +any Nexus acknowledges in original storm order, regardless of private face. +Seats without public card presence, with no affected group, or allied receive +no impossible prompt. Only the current actual Fremen-card holder may Use. +Pass preserves the card; Use discards it once and protects only that owner. +The entire frozen queue closes before any casualty or spice removal. Its +original public membership survives an unrelated anytime card exchange. +Truthtrance and unfinished Traitor returns retain priority. + +`game/nexus-fremen-protection.ts` binds original UUID/turn/territory/origin and +actual pile/index or summon parent, ordered acknowledgements and one protected +owner. The engine resumes original `devour`/`afterWorm` once. Original protected +victim skipping also leaves a protected No-Field hidden; unprotected markers +still use native reserve-capped revelation/casualties. The public quote never +reads denomination. Unrelated dynamic-board groups do not invalidate this +printed-territory occurrence. Completed receipts do not require the singleton +to remain discarded after a later legitimate deck reshuffle. + +Genuine Basic/Advanced setup, original worm/alliance/closing draw, paid +Hagga Basin shipment and Advanced free accompanied advisor produce the source. +Only undealt physical deck order and scoped scalar producer entropy are staged. +The original Great Maker path is also exercised: protection precedes its +unchanged vote, and the replacement is drawn only after that vote. Native +Fremen protection/riding/Cunning remain separate. + +The related nine-file union passes86/86. Actual390px neutral Continue then +holder Use keeps the Emperor's one ordinary counter/reserves19/Tanks0/spice10, +devours the BG advisor into Tanks1, removes four Hagga Basin spice, discards +one Nexus, grants no ride and restores the original Accept spice blow control. +That control accepts the resumed Sihaya Ridge blow. No horizontal overflow +or browser errors. Four minimal legal profiles answer their own opportunity. +Final types/lint/build pass. Four normal/Great Maker Basic/Advanced API runs +observe the same preserved costs and original vote→replacement sequencing. +Owned QA host/tab/scaffolding is removed; proof receipts remain outside Git. +Broader module/No-Field continuations, comprehensive recovery, complete games +and deployed acceptance remain due. ## Secret Ally revival The card's total-of-three wording and the native alliance's ordinary free-three benefit support a **free-rate interpretation within ordinary revival accounting**, rather than three additional counters beyond the usual allowance. This is source composition, not an explicit Nexus FAQ. Preserve it as the proposed contract; do not silently apply Emperor's separate extra-return producer. The current first path is a deliberate subset of that proposed contract: with Fremen absent, an unallied base-faction holder in Basic or Advanced Revival may spend the physical Nexus card to transfer exactly three eligible forces from Tanks to reserves at zero spice cost. It is available before any ordinary force return, with no competing expansion/module revival modifier, pending response or decision. The three returned forces consume the ordinary allowance and free-force ledger; they are **not** Emperor-style extra returns. The Advanced one-elite-per-turn cap and physical elite census apply. The server's private offer provides the eligible elite counts; the action validates its current turn and group. A signed before/after receipt and independent event markers persist through JSON saves. -The bounded classic Tech/Advanced Stronghold composition now calls the existing Axlotl activity hook after this actual free batch; spice stays on the token until native phase-end settlement, without changing these ordinary ledgers. See [the composition contract](NEXUS_CARD_RULES.md#classic-tech-and-stronghold-composition--4-october-2026). Tleilaxu income and Homeworld arrivals remain excluded: those broader continuations require the native pending-revival pipeline, not this quiet transfer. Fewer-than-three Tanks, previous force returns and higher free rates/modifiers still remain unavailable rather than guessing a ruling. Protection remains unimplemented; Cunning's bounded path is separate. +The bounded classic Tech/Advanced Stronghold composition now calls the existing Axlotl activity hook after this actual free batch; spice stays on the token until native phase-end settlement, without changing these ordinary ledgers. See [the composition contract](NEXUS_CARD_RULES.md#classic-tech-and-stronghold-composition--4-october-2026). Tleilaxu income and Homeworld arrivals remain excluded: those broader continuations require the native pending-revival pipeline, not this quiet transfer. Fewer-than-three Tanks, previous force returns and higher free rates/modifiers remain unavailable rather than guessing a ruling. The independent protection prototype above and native Cunning remain separate producers. The separate fresh classic `leader-skills --nexus-cards` composition preserves the same ordinary/free-three ledgers, zero wallet cost, actual counters and diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index fa52b584..94c05b59 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -549,6 +549,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t | Nexus responses and composition | Betrayal private-response timing, Emperor fewer-than-five, fresh advisor/storm cases, Duke restoration and remaining faction-specific combinations remain source-bound. Tleilaxu Secret Ally now has the explicitly provisional recommended combined policy in its own row below; it is not borrowed authority from native revival or Ghola. | [Common boundaries](NEXUS_CARD_RULES.md#remaining-source-questions-and-release-boundaries) and individual faction source/runtime documents | | Tleilaxu Secret Ally revival | Under choose-recommended-option priority, a quiet unallied actual holder with Tleilaxu absent returns one mandatory own dead leader free, using the ordinary leader slot, and zero-to-five exact counters at one bank spice each. The card shares an ordinary ceiling expanded to at least five; prior normal returns count, larger native/Recruits allowances remain, and the fixed card group is never doubled. Only the unavailable-roster trigger is waived; repeated-death/face-down and special custody rules remain, with original ordinary cycles preserved. No force-only use, native Tleilaxu income or nonexistent cancellation owner is invented. This is provisional implementation policy, not publisher clarification or a separately answered numerical user ruling. | [Recommended combined policy](NEXUS_TLEILAXU_RULES.md#recommended-combined-revival-prototype--14-october-2026) | | Bene Gesserit borrowed Voice | Choose-recommended-option priority selects one Karama-cancellable named Voice advantage when BG is absent. Every fresh Nexus battle has the same attacker-first two-combatant continuation before Prescience/inspection/plans, independent of hidden face. Only the actual unallied holder can Use. Declaration spends the Nexus; cancellation removes only Voice and retains the spent card, receipt and native preparation. No retrofit/reset of old preparation. This is explicit provisional implementation policy, not retrieved publisher clarification. | [Recommended borrowed Voice](NEXUS_BENE_GESSERIT_RULES.md#recommended-borrowed-voice-prototype--14-october-2026) | +| Fremen Secret Ally protection | Choose-recommended-option priority adopts independent printed protection before one original worm's destruction, not a borrowed named native advantage or riding. Publicly possible unallied threatened held-Nexus seats acknowledge in frozen storm order regardless of face; only the actual Fremen holder can spend. Protect that owner's original groups only, retain other casualties/spice removal and original Great Maker vote/Nexus continuation. No invented additional Karama response or native provider. Protected No-Field uses native protected-victim skipping; broader combinations remain unverified. This is visible provisional implementation policy, not publisher clarification or universal reaction priority. | [Recommended independent protection](NEXUS_FREMEN_RULES.md#recommended-independent-protection--14-october-2026) | | Moritani Atomics after alliance change | The publisher’s permanent −1 text does not identify later-ally treatment, and prior requested searches found no clarification. Under choose-recommended-option priority, retain the original affected pair permanently: Moritani and its ally at detonation. Later Nexus and Enemy of My Enemy alliances are allowed without moving the penalty to a new ally; an unallied detonation gains no later ally target. Existing stored activation targets migrate immutably, without replaying effects. This is a conservative provisional implementation interpretation, not publisher clarification or a separately answered numerical user ruling. | [Recommended persistent targets](MORITANI_TERROR_RULES.md#recommended-persistent-activation-targets--14-october-2026) | | Moritani Sneak Attack entry classification | The printed verb is “Send”; no retrieved ruling classifies the off-planet reserve transfer as a shipment for other arrival powers. The engine now **explicitly adopts** the supported shipment composition: a positive Sneak Attack in Shipment and Movement accrues the Heighliners token once for its owner, then offers the Bene Gesserit Intrusion (Advanced, with fighters present) and the free spiritual-advisor shipment, with the committed Terror entry resolving first; a zero-force resolution accrues nothing and opens no reaction. This order is an implementation interpretation, not a publisher ruling. | [Source boundary and adopted classification](MORITANI_ASSASSINATION_SNEAK.md#adopted-shipment-classification-7-october-2026) | | Orgiz collection | Basic retains the publisher-p13 per-blow ambiguity and provisional one-transfer-per-positive-deposit mapping. Authorized supplied Advanced rulebook physical24 says each time Collection occurs in a territory containing spice: one transfer per actual positive territory Collection per fighter occupant, never extra transfers for multiple source keys. Canonical ordinary deposit receipts identify actual payers, including co-present collectors; force presence and old wallets alone do not qualify. Under choose-recommended-option priority, owners and eligible foreign ordinary payers resolve in original storm order. Shared Ecaz lots in both modes wait for actual bilateral allocation, then consider positive foreign recipients Ecaz first, ally second; Advanced charges once per territory, Basic counts actually reduced positive source deposits in that territory. Self, zero shares and empty purses are skipped; balances track every transfer without debt. Ordering and the Basic mapping are provisional implementations, not separate numerical user answers or publisher rulings. | [Advanced source cutover and Basic boundary](DISCOVERY_PROTOTYPE.md#authorized-advanced-collection-wording) | diff --git a/game/admin-seat-ai-shape.ts b/game/admin-seat-ai-shape.ts index 33b18187..913ae04f 100644 --- a/game/admin-seat-ai-shape.ts +++ b/game/admin-seat-ai-shape.ts @@ -108,6 +108,7 @@ const decisions = { greatMakerRide: true, wormRide: true, nexusFremenCunningOffer: true, + nexusFremenProtection: true, nexusFremenCunningRide: true, moritaniRetention: true, moritaniSpecialKarama: true, diff --git a/game/bots.ts b/game/bots.ts index 76edf24b..b17c12e3 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -2684,6 +2684,12 @@ function policyActions(g: GameView): Action[] { if (d.kind === 'jacurutuDial') return g.jacurutuDial?.choices.map((_,allocation) => ({type:'decision',event:d.event,allocation})) ?? []; if (d.kind === 'greatMakerVote' || d.kind === 'greatMakerRide') return greatMakerBotActions(g); + if (d.kind === 'nexusFremenProtection') { + const offer = g.nexusFremenProtection; + return offer && !offer.blocked + ? [{ type: 'nexusFremenProtection', event: offer.event, use: offer.canUse }] + : []; + } if (d.kind === 'nexusFremenCunningOffer') return [{ type: 'decision', event: d.event, accept: g.nexusCards?.card === 'fremen' && me.faction === 'fremen' && diff --git a/game/engine.ts b/game/engine.ts index f1a2cbc1..e8403e6d 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -48,6 +48,7 @@ import { EMPEROR_NEXUS_REVIVALS, emperorNexusEvent, emperorNexusSignature, emper import { emperorNexusPurchaseEvent, quoteEmperorNexusPurchase, signEmperorNexusPurchase, validateEmperorNexusPurchase, type EmperorNexusPurchase } from './nexus-emperor-purchase'; import { FREMEN_NEXUS_FREE_FORCES, fremenNexusRevivalOffer, fremenNexusRevivalPools, fremenNexusRevivalSignature, validateFremenNexusRevival, type FremenNexusRevival } from './nexus-fremen-revival'; import { captureFremenCunningOccurrence, quoteFremenCunning, quoteFremenCunningRide, validateFremenCunningSelection, type FremenCunningOccurrence, type FremenCunningRideAuthorization, type FremenCunningSelection } from './nexus-fremen-cunning'; +import { createNexusFremenProtection, currentNexusFremenProtectionOwner, answerNexusFremenProtection, validateNexusFremenProtection, type NexusFremenProtectionFrame, type NexusFremenProtectionInput } from './nexus-fremen-protection'; import { truthKnowledgeOf } from './truthtrance-knowledge'; import { createMoritaniAssassinateOpportunity, moritaniAssassinateTrigger, moritaniAssassinateChoices, quoteMoritaniAssassinate, moritaniAssassinateSignature, validateMoritaniAssassinate, type MoritaniAssassinateState, type MoritaniAssassinateReceipt } from './moritani-assassinate'; import { createNexusGuildSecretAlly, validateNexusGuildSecretAlly, quoteNexusGuildSecretShipment, type NexusGuildSecretAllyReceipt } from './nexus-guild-secret-ally'; @@ -958,6 +959,7 @@ export type Decision = } | { kind: 'wormPlacement'; player: string } | { kind: 'wormProtection'; player: string; territory: string; ally: string } + | { kind: 'nexusFremenProtection'; player: string; event: string } | { kind: 'discoveryDiscard'; player: string; event: string } | { kind: 'discoveryEntry'; player: string; event: string } | { kind: 'ecologicalStorm'; player: string; event: string } @@ -1899,6 +1901,8 @@ export type Game = { /** Optional independent module; complete effect coverage is still release-gated. */ nexusCards?: { cards: NexusState | null; phase: NexusCardPhase | null } | null; nexusTraitorExchanges?: NexusTraitorExchange[]; + /** Original public threatened-seat queue; a spent card protects only its owner. */ + nexusFremenProtection?: NexusFremenProtectionFrame; /** An initially empty actual worm occurrence; the optional card is not disclosed by this offer. */ nexusFremenCunningOffer?: { occurrence: FremenCunningOccurrence; owner: string } | null; /** Accepted appearances wait for post-Nexus riding, then retain completion custody. */ @@ -10675,6 +10679,26 @@ function devour( } function beginWorm(g: Game, t: string, origin: 'natural' | 'additional' | 'summoned' = 'natural') { const fremen = byFaction(g, 'fremen'); + if (!fremen && g.nexusCards?.cards && g.players.some(p => + !p.ally && g.nexusCards!.cards!.hands[p.id] && at(p, t) > 0)) { + const frame = nexusRule(() => createNexusFremenProtection({ + event: crypto.randomUUID(), turn: g.turn, territory: t, origin, + ...(origin === 'summoned' ? { parent: g.summonedWorm?.event } + : { pile: g.spiceSequence?.pile, + index: g.spiceSequence ? g.spiceDiscard[g.spiceSequence.pile].length - 1 : undefined }), + order: g.order, + players: g.players.map(p => ({ id: p.id, faction: p.faction, ally: p.ally, + forces: p.forces, noField: p.noField, hasNexus: !!g.nexusCards!.cards!.hands[p.id] })), + })); + if (frame) { + requireRule(g.nexusFremenProtection?.stage !== 'choose', + 'Finish the original worm protection before another appearance.'); + g.nexusFremenProtection = frame; + g.decision = { kind: 'nexusFremenProtection', + player: currentNexusFremenProtectionOwner(frame)!, event: frame.event }; + return; + } + } // An empty appearance offers the same decision with or without the private // Fremen card; the offer itself must not disclose its custody. if ((origin !== 'summoned' || (g.summonedWorm?.event && @@ -10716,6 +10740,87 @@ function beginWorm(g: Game, t: string, origin: 'natural' | 'additional' | 'summo }; } else wormSurvival(g, t); } +function nexusFremenProtectionInput(g: Game, frame: NexusFremenProtectionFrame): NexusFremenProtectionInput { + return { + event: frame.event, turn: g.turn, territory: frame.territory, origin: frame.origin, + ...(frame.origin === 'summoned' ? { parent: g.summonedWorm?.event } + : { pile: g.spiceSequence?.pile, + index: g.spiceSequence ? g.spiceDiscard[g.spiceSequence.pile].length - 1 : undefined }), + order: g.order, + players: g.players.map(p => ({ id: p.id, faction: p.faction, ally: p.ally, + forces: p.forces, noField: p.noField, + // An anytime Harkonnen exchange may spend an irrelevant card after this + // public membership was captured; it cannot erase the original answer. + hasNexus: frame.order.includes(p.id) })), + }; +} +function nexusFremenProtectionIntegrity(g: Game) { + const frame = g.nexusFremenProtection; + const controls = [g, g.pendingExchange, g.pendingNullentropy?.resume, + g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume, + g.summonedWorm?.resume]; + const decisions = controls.flatMap(control => + control?.decision?.kind === 'nexusFremenProtection' ? [control.decision] : []); + if (!frame || frame.stage === 'complete') { + requireRule(!decisions.length, 'Worm protection has lost its original active occurrence.'); + return; + } + requireRule(g.status === 'playing' && g.phase === 1 && g.nexusCards?.cards && + !byFaction(g, 'fremen'), 'Worm protection requires its original absent-Fremen Nexus module.'); + nexusRule(() => validateNexusFremenProtection(nexusFremenProtectionInput(g, frame), frame)); + requireRule(decisions.length === 1 && decisions[0].event === frame.event && + decisions[0].player === currentNexusFremenProtectionOwner(frame), + 'The original worm protection has lost its current threatened seat.'); + if (frame.origin === 'summoned') + requireRule(g.summonedWorm?.territory === frame.territory, + 'Worm protection has lost its summoned territory.'); + else { + const card = frame.pile === undefined || frame.index === undefined ? undefined : + g.spiceDiscard[frame.pile][frame.index]; + requireRule(card && 'worm' in card && !card.suppressed, + 'Worm protection has lost its actual unconsumed appearance.'); + } + if (frame.protected) requireRule(g.nexusCards.cards.discard.includes('fremen') && + !getPlayer(g, frame.protected).ally, + 'The pending worm protection has lost its spent card or unallied owner.'); +} +function nexusFremenProtectionOffer(g: Game, id: string) { + const frame = g.nexusFremenProtection; + if (!frame || currentNexusFremenProtectionOwner(frame) !== id || + g.decision?.kind !== 'nexusFremenProtection' || g.decision.player !== id || + g.decision.event !== frame.event) return null; + const p = getPlayer(g, id); + const blocked = g.truthtrance || pendingNexusTraitors(g) || g.response || + g.pendingKarama || g.pendingTreacheryDiscard || g.pendingNullentropy + ? 'Finish the current private response before worm protection.' : null; + return { event: frame.event, territory: frame.territory, owner: id, + canUse: !frame.protected && !p.ally && + g.nexusCards?.cards?.hands[id] === 'fremen' && at(p, frame.territory) > 0, + blocked }; +} +function playNexusFremenProtection(g: Game, p: Player, action: Action) { + const frame = g.nexusFremenProtection, offer = nexusFremenProtectionOffer(g, p.id); + requireRule(frame && offer && !offer.blocked && action.event === offer.event && + typeof action.use === 'boolean' && + Object.keys(action).every(key => ['type', 'event', 'use'].includes(key)), + 'Choose only your current worm protection event with Use or Pass.'); + requireRule(!action.use || offer.canUse, + 'Only the actual unallied Fremen Nexus holder may protect its threatened forces.'); + const next = nexusRule(() => answerNexusFremenProtection(frame, p.id, action.use as boolean)); + if (action.use) { + g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); + log(g, `${p.name} spent Fremen Nexus Secret Ally to protect only their forces from this Shai-Hulud. Provisional policy: this independent protection has no additional Karama response.`, + { faction: p.faction, name: 'Fremen Nexus protection' }); + } + g.nexusFremenProtection = next; + const owner = currentNexusFremenProtectionOwner(next); + if (owner) g.decision = { kind: 'nexusFremenProtection', player: owner, event: next.event }; + else { + g.decision = null; + devour(g, next.territory, next.protected ?? undefined); + afterWorm(g); + } +} function currentFremenCunningResponse(g: Game, response: ResponseWindow) { const ride = g.nexusFremenCunningRides?.find(row => row.stage === 'pending' && row.occurrence.event === response.intent); @@ -11099,6 +11204,11 @@ function greatMakerIntegrity(g: Game) { }); if (frame.stage === 'worm') requireRule( controls.some(control => control?.decision?.kind === 'wormProtection' && control.decision.territory === frame.territory && control.decision.player === byFaction(g,'fremen')?.id && control.decision.ally === byFaction(g,'fremen')?.ally) || + controls.some(control => control?.decision?.kind === 'nexusFremenProtection' && + g.nexusFremenProtection?.stage === 'choose' && + g.nexusFremenProtection.territory === frame.territory && + g.nexusFremenProtection.pile === frame.pile && + g.nexusFremenProtection.index === frame.index) || responses.some(response => (response?.kind === 'wormSurvival' || response?.kind === 'wormAllyProtection') && response.location === frame.territory && response.owner === byFaction(g,'fremen')?.id && (!response.recipient || response.recipient === byFaction(g,'fremen')?.ally)), 'The Great Maker has lost its pending worm survival or protection interaction.'); @@ -20716,6 +20826,7 @@ function marketGholaIntegrity(g: Game) { nexusBorrowedVoiceIntegrity(g); nexusFremenRevivalIntegrity(g); nexusFremenCunningIntegrity(g); + nexusFremenProtectionIntegrity(g); nexusEcazBetrayalIntegrity(g); nexusTraitorIntegrity(g); nexusFaceDancerIntegrity(g); @@ -28944,6 +29055,7 @@ function applyActionInner( if (t === 'nexusFaceDancers') { playNexusFaceDancers(g, p, action); return g; } if (t === 'nexusTleilaxuRevival') { playNexusTleilaxuRevival(g,p,action); return g; } if (t === 'nexusBorrowedVoice') { playNexusBorrowedVoice(g, p, action); return g; } + if (t === 'nexusFremenProtection') { playNexusFremenProtection(g, p, action); return g; } if (t === 'nexusSuboids') { playNexusSuboids(g, p, action); return g; } if (t === 'nexusAdvisors') { playNexusAdvisors(g, p, action); return g; } if (t === 'nexusSardaukar') { playNexusSardaukar(g,p,action); return g; } @@ -29333,6 +29445,8 @@ function applyActionInner( } if (g.decision) { const decision = g.decision; + requireRule(decision.kind !== 'nexusFremenProtection', + 'Use only the original worm protection event with Use or Pass.'); if (decision.kind === 'nexusIxianReplacement') { requireRule(false, 'Use only the current purchased-card event with Use or Pass.'); } @@ -33213,6 +33327,7 @@ export function viewGame(state: Game, id: string) { nexusBgBetrayal: nexusBgBetrayalOffer(g, id), nexusBorrowedVoice: nexusBorrowedVoiceOffer(g, id), nexusFremenRevival: fremenNexusRevivalOffer(g,id), + nexusFremenProtection: nexusFremenProtectionOffer(g, id), nexusMoritani: nexusMoritaniOffer(g,id), nexusMoritaniBetrayal: moritaniBetrayalOffer(g,id, g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)), diff --git a/game/nexus-fremen-protection.ts b/game/nexus-fremen-protection.ts new file mode 100644 index 00000000..65de4fae --- /dev/null +++ b/game/nexus-fremen-protection.ts @@ -0,0 +1,241 @@ +import { TERRITORIES, splitLocation, validLocation } from './board'; +import { SPICE_CARDS } from './cards'; +import { presenceAt, type ForcePresence } from './force-presence'; + +/** Public identity of one accepted worm, captured before its original devouring. */ +export type NexusFremenProtectionContext = Readonly<{ + event: string; + turn: number; + territory: string; + origin: 'natural' | 'additional' | 'summoned'; + pile?: 0 | 1; + index?: number; + parent?: string; +}>; + +/** Deliberately excludes card faces, No-Field values and token inventory. */ +export type NexusFremenProtectionPlayer = ForcePresence & Readonly<{ + id: string; + faction: string; + ally?: string | null; + hasNexus: boolean; +}>; + +export type NexusFremenProtectionInput = NexusFremenProtectionContext & Readonly<{ + order: readonly string[]; + players: readonly NexusFremenProtectionPlayer[]; +}>; + +export type NexusFremenProtectionFrame = NexusFremenProtectionContext & Readonly<{ + stage: 'choose' | 'complete'; + order: readonly string[]; + passed: readonly string[]; + protected: string | null; +}>; + +function requireProtection(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +const identity = (value: unknown): value is string => + typeof value === 'string' && value.trim().length > 0; +const object = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); + +function validateContext(context: NexusFremenProtectionContext): void { + requireProtection( + object(context) && identity(context.event) && + Number.isSafeInteger(context.turn) && context.turn > 0 && + TERRITORIES.some((territory) => territory.id === context.territory && + (territory.type === 'sand' || SPICE_CARDS.some(([id]) => id === territory.id))) && + ['natural', 'additional', 'summoned'].includes(context.origin), + 'Invalid original Fremen protection occurrence.', + ); + requireProtection( + context.origin === 'summoned' + ? identity(context.parent) && context.pile === undefined && context.index === undefined + : context.parent === undefined && + (context.pile === undefined || context.pile === 0 || context.pile === 1) && + (context.index === undefined || + (Number.isSafeInteger(context.index) && context.index >= 0)) && + (context.origin !== 'natural' || + (context.pile !== undefined && context.index !== undefined)), + 'Invalid original Fremen protection source binding.', + ); +} + +function validateIds(ids: readonly string[]): void { + requireProtection(Array.isArray(ids), 'Invalid Fremen protection decision order.'); + const seen = new Set(); + for (const id of ids) { + requireProtection( + identity(id) && !seen.has(id), + 'Invalid Fremen protection decision order.', + ); + seen.add(id); + } +} + +function validateInput(input: NexusFremenProtectionInput): void { + validateContext(input); + validateIds(input.order); + requireProtection(Array.isArray(input.players), 'Invalid public protection seats.'); + const ids = new Set(); + for (const player of input.players) { + requireProtection( + object(player) && identity(player.id) && !ids.has(player.id) && + identity(player.faction) && typeof player.hasNexus === 'boolean' && + object(player.forces), + 'Invalid public protection seat.', + ); + ids.add(player.id); + for (const [key, count] of Object.entries(player.forces)) { + const at = splitLocation(key); + if (at.territory !== input.territory) continue; + requireProtection( + key === `${at.territory}:${at.sector}` && + validLocation(at.territory, at.sector) && + typeof count === 'number' && Number.isSafeInteger(count) && count >= 0, + 'Invalid public protection force presence.', + ); + } + if (player.noField !== undefined && player.noField !== null) { + requireProtection(object(player.noField), 'Invalid public No-Field presence.'); + const deployed = player.noField.deployed; + requireProtection( + deployed === null || + (object(deployed) && object(deployed.location) && + identity(deployed.location.territory) && + typeof deployed.location.sector === 'number' && + Number.isSafeInteger(deployed.location.sector) && + (deployed.location.territory !== input.territory || + validLocation(deployed.location.territory, deployed.location.sector))), + 'Invalid public No-Field location.', + ); + } + } + requireProtection( + input.order.length === ids.size && input.order.every((id) => ids.has(id)), + 'Fremen protection needs the original storm order of every seat.', + ); + for (const player of input.players) + requireProtection( + player.ally === undefined || player.ally === null || + (identity(player.ally) && player.ally !== player.id && ids.has(player.ally)), + 'Invalid public protection alliance.', + ); +} + +function candidates(input: NexusFremenProtectionInput): string[] { + if (input.players.some((player) => player.faction === 'fremen')) return []; + const eligible = new Set(); + for (const player of input.players) + if (!player.ally && player.hasNexus && presenceAt(player, input.territory) > 0) + eligible.add(player.id); + return input.order.filter((id) => eligible.has(id)); +} + +function validateFrame(frame: NexusFremenProtectionFrame): void { + validateContext(frame); + const keys = ['event', 'turn', 'territory', 'origin', 'stage', 'order', 'passed', 'protected']; + if (frame.pile !== undefined) keys.push('pile'); + if (frame.index !== undefined) keys.push('index'); + if (frame.parent !== undefined) keys.push('parent'); + requireProtection( + Object.keys(frame).sort().join(',') === keys.sort().join(',') && + (frame.stage === 'choose' || frame.stage === 'complete'), + 'Invalid saved Fremen protection frame.', + ); + validateIds(frame.order); + validateIds(frame.passed); + requireProtection( + frame.order.length > 0 && frame.passed.length <= frame.order.length && + frame.passed.every((id, index) => id === frame.order[index]), + 'Fremen protection acknowledgements must be an original ordered prefix.', + ); + requireProtection( + frame.protected === null || + (identity(frame.protected) && frame.passed.includes(frame.protected)), + 'Fremen protection must belong to one acknowledged original owner.', + ); + requireProtection( + (frame.stage === 'complete') === (frame.passed.length === frame.order.length), + 'Fremen protection must close the entire original queue.', + ); +} + +/** Public candidates only; a wrong private card face still receives the same queue. */ +export function createNexusFremenProtection( + input: NexusFremenProtectionInput, +): NexusFremenProtectionFrame | null { + validateInput(input); + const order = candidates(input); + if (!order.length) return null; + return { + event: input.event, + turn: input.turn, + territory: input.territory, + origin: input.origin, + ...(input.pile === undefined ? {} : { pile: input.pile }), + ...(input.index === undefined ? {} : { index: input.index }), + ...(input.parent === undefined ? {} : { parent: input.parent }), + stage: 'choose', + order, + passed: [], + protected: null, + }; +} + +export function currentNexusFremenProtectionOwner( + frame: NexusFremenProtectionFrame, +): string | null { + validateFrame(frame); + return frame.stage === 'complete' ? null : frame.order[frame.passed.length]; +} + +/** The parent must authenticate and spend the actual Fremen card before committing + * this result. `passed` is the acknowledgement prefix, including a Use answer. + * Protection does not skip any remaining acknowledgement or grant worm riding. */ +export function answerNexusFremenProtection( + frame: NexusFremenProtectionFrame, + actor: string, + use: boolean, +): NexusFremenProtectionFrame { + validateFrame(frame); + requireProtection(typeof use === 'boolean', 'Choose Use or Pass for Fremen protection.'); + requireProtection( + frame.stage === 'choose' && actor === frame.order[frame.passed.length], + 'Only the current original owner may answer Fremen protection.', + ); + requireProtection(!use || frame.protected === null, 'This worm already has its protected owner.'); + const passed = [...frame.passed, actor]; + return { + ...frame, + passed, + protected: use ? actor : frame.protected, + stage: passed.length === frame.order.length ? 'complete' : 'choose', + }; +} + +/** Validate against the original public pre-devour snapshot, not live hands after + * expenditure or board presence after devouring. This is not card entitlement, + * an event-history receipt, or authority to retrofit an already-devoured worm. */ +export function validateNexusFremenProtection( + input: NexusFremenProtectionInput, + frame: NexusFremenProtectionFrame, +): void { + validateInput(input); + validateFrame(frame); + requireProtection( + frame.event === input.event && frame.turn === input.turn && + frame.territory === input.territory && frame.origin === input.origin && + frame.pile === input.pile && frame.index === input.index && frame.parent === input.parent, + 'Fremen protection has lost its original occurrence identity.', + ); + const order = candidates(input); + requireProtection( + order.length === frame.order.length && + order.every((id, index) => id === frame.order[index]), + 'Fremen protection has lost its original public candidates and storm order.', + ); +} diff --git a/game/reference.ts b/game/reference.ts index 87ac9879..0591df29 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4570,7 +4570,8 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Emperor Cunning requires an Advanced battle with at least five ordinary Emperor counters and no actual Sardaukar, before submitting your plan. The played Nexus card opens one Karama response. Allowing it makes five ordinary counters count as Sardaukar for this battle; cancellation prevents the entire enhancement and leaves the Nexus card spent.', 'Temporary Sardaukar use the applicable strength and support rules, including the Fremen exception, but remain physically ordinary counters. Their losses go to Tanks as ordinary counters, without changing starred reserves or Tanks. Existing inspected or promised plans remain binding. The role ends after this battle; fewer-than-five groups remain unavailable. The separate Secret Ally revival and normal bank-auction purchase have connected controls; seller-paid and forced Emperor payments remain unfinished.', 'Emperor Betrayal has a battle alternative in Advanced play: an unallied opponent holding the Emperor Nexus card can spend it during the ordinary pre-plan Sardaukar response to suppress actual Sardaukar strength for this battle. Every seat receives the same response opportunity even with no card, keeping hidden custody private. The starred pieces remain physical Sardaukar; no card grants a new force or cancels their separate support benefit.', - 'With Fremen absent, an unallied holder may spend Fremen Secret Ally during Revival to return exactly three eligible forces from Tanks to reserves at no spice cost. Unlike Emperor Secret Ally, these three consume the ordinary force and free-force allowances, including the Advanced one-elite cap. This bounded base-faction path keeps prior-return and competing-revival guards; supported classic Tech/Stronghold composition invokes actual Axlotl activity after the batch, independently of allowance accounting. Both free returns collect Tech only at phase end, never as spendable advance spice. Partial returns, prior returns, higher free rates and separate sandworm protection remain unavailable.', + 'With Fremen absent, an unallied holder may spend Fremen Secret Ally during Revival to return exactly three eligible forces from Tanks to reserves at no spice cost. Unlike Emperor Secret Ally, these three consume the ordinary force and free-force allowances, including the Advanced one-elite cap. This bounded base-faction path keeps prior-return and competing-revival guards; supported classic Tech/Stronghold composition invokes actual Axlotl activity after the batch, independently of allowance accounting. Both free returns collect Tech only at phase end, never as spendable advance spice. Partial returns, prior returns and higher free rates remain guarded; independent sandworm protection is the separate connected operation below.', + 'Before one accepted worm devours forces with Fremen absent, publicly possible unallied threatened held-Nexus seats acknowledge in original storm order regardless of secret face. Only the actual Fremen holder may spend to save their own groups. Pass keeps the card; all acknowledgements precede casualties. Under the explicit provisional independent-operation policy, no extra Karama provider or riding is invented. Original other casualties, spice removal, Great Maker vote and next blow/Nexus remain. Protected No-Field uses native protected-victim skipping; wider marker/module acceptance remains open.', 'Native Fremen Cunning has a bounded classic Nexus path, optionally retaining original Tech Tokens and Advanced Stronghold Cards: when a natural, accepted additional, or Advanced special-Karama summoned worm appears in a territory initially containing no forces, Fremen may spend their own Nexus card before the worm resolves. The special summon retains its original Spice Blow parent. After the applicable Nexus, choose some forces from one occupied desert territory and ride to a legal Arrakis destination without using normal movement or changing original component custody/income. The choice appears even without the secret card, so declining does not disclose custody. Karama provisionally prevents only the additional remote ride; the original worm still resolves and the card remains spent. An interrupted existing Cunning control is restored, and a selected ride waits for the summoned Nexus without granting a second Cunning offer; Great Maker, dynamic destinations and other optional-module combinations await integration.', 'CHOAM Secret Ally has a working Collection trade: when CHOAM is absent, an unallied holder may discard one Worthless card and the Nexus card for two bank spice. After a battle victory, every winner in an eligible Nexus game receives the same use-or-continue window; an unallied CHOAM-card holder may instead spend that card to inspect one random unused card in the opposing combatant’s current hand. Played battle cards remain excluded even if retained. The inspected face is private, while use is public. The universal window protects concealed card custody; this is an interface interpretation, not a publisher-prescribed prompt. An ordinary Karama response to this Secret Ally inspection remains unestablished.', 'With CHOAM seated, an unallied holder may spend CHOAM Betrayal at a clean play boundary to discard one uniformly random held CHOAM Treachery Card without spice compensation. The target name is public, but its hand count is offered only during Bidding; the holder cannot pick or inspect the card before the ordinary discard reveal.', @@ -4596,11 +4597,11 @@ export const RULE_TOPICS: RuleTopic[] = [ ], related: ['ecaz-modules', 'spice-blow', 'alliance-funding', 'choam-worthless', 'choam-kull', 'nexus-choam-kull', 'nexus-richese-betrayal', 'nexus-guild-betrayal', 'nexus-ixian-replacement', 'nexus-ixian-betrayal'], checklist: [ - { area: 'Implementation', status: 'Partial', detail: 'Existing physical effects remain connected. Tleilaxu Secret Ally retains its provisional shared revival policy. Absent-BG borrowed Voice now uses a neutral attacker-first pre-inspection queue, actual unallied holder and one physical card declaration. Original Karama can cancel only Voice while preserving spent Nexus, receipt and Prescience. Native command checks are shared; old preparations are not retrofitted. Exceptional custody, worm protection and wider combinations remain open.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts', 'game/nexus-borrowed-voice.ts', 'game/combat-response-quote.ts', 'game/karama-context.ts'] }, - { area: 'Player controls', status: 'Partial', detail: 'Existing private lifecycle and Tleilaxu free-leader/paid-counter controls remain. Borrowed Voice adds neutral Continue and current-holder canonical require/forbid selection before Prescience, with a visible provisional cancellation policy. Original Karama and native preparation controls continue. Remaining reactive combinations and complete modes are open.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx', 'components/battle-preparation.tsx'] }, - { area: 'AI', status: 'Partial', detail: 'All four minimal legal profiles retain existing effect policies, actual Tleilaxu combined returns, and now answer both borrowed Voice opportunities through their own projected offer. Cardless Continue does not spend; eligible Use opens the original response. Complete strategy and calibration remain deferred and unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts'] }, - { area: 'Documentation', status: 'Partial', detail: 'Common rules and twelve faces retain source-bound remaining custody, inquiry, nested worm, Great Maker, fresh advisor/storm and combination questions. Borrowed Voice explicitly adopts recommended cancellable named-advantage semantics and a neutral pre-Prescience queue; this is not publisher clarification. Existing Richese and Guild payment/privacy contracts retain their own limits. Full mode and response-priority acceptance remain open.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/NEXUS_GUILD_RULES.md'] }, - { area: 'Verification', status: 'Partial', detail: 'Borrowed Voice related4-file union50/50, types/lint and build pass. Genuine Basic/Advanced setup, paid Karama, worm/alliance/Nexus draw and reserve shipment produce the original battle; cases exercise order, spend, cancellation, compliance, impossible compliance, all-pass and four legal profiles. Actual390px Continue, projectile command, Karama, native Prescience decline and Seal battle plan preserve one spent Nexus and canceled receipt without overflow/errors after QA configuration repair. Earlier Tleilaxu and mixed-family evidence remains bounded; full modes, assurance and deployed acceptance stay open.', evidence: ['tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts', 'tests/nexus-borrowed-voice.test.ts', 'tests/fixture-nexus-borrowed-voice.ts', 'tests/nexus-borrowed-voice-runtime.test.ts'] }, + { area: 'Implementation', status: 'Partial', detail: 'Existing physical effects, Tleilaxu shared revival and borrowed Voice remain connected. Absent-Fremen protection now binds one original pre-devour occurrence and public threatened held-Nexus storm order; only the actual unallied holder spends. It preserves that owner only and resumes native other casualties, spice removal, Great Maker vote and replacement once. No extra Karama provider, ride or native identity is invented under the visible provisional policy. Wider custody and combinations remain open.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts', 'game/nexus-borrowed-voice.ts', 'game/combat-response-quote.ts', 'game/karama-context.ts', 'game/nexus-fremen-protection.ts', 'game/admin-seat-ai-shape.ts', 'game/richese-cancellation.ts'] }, + { area: 'Player controls', status: 'Partial', detail: 'Existing lifecycle, Tleilaxu and borrowed Voice controls remain. Worm protection adds owner-only neutral Continue or actual-holder Use before devouring, with a visible independent-operation policy. The original Accept spice blow and Great Maker vote remain available after settlement. Nexus table guidance now lists connected effects and supply-only Moritani Cunning honestly. Remaining combinations and complete modes are open.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx', 'components/battle-preparation.tsx'] }, + { area: 'AI', status: 'Partial', detail: 'All four minimal legal profiles retain existing effects, Tleilaxu combined returns and borrowed Voice, and now answer only their own worm protection offer: actual holder Use or irrelevant-face Pass. Original later worm/Nexus and vote policies continue. This is legal participation, not strategy or difficulty calibration; both remain deferred and unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts'] }, + { area: 'Documentation', status: 'Partial', detail: 'Common rules and twelve faces retain remaining custody, inquiry, native nested Cunning, fresh advisor/storm and combination questions. Source documents explicitly distinguish cancellable named borrowed Voice from independent absent-Fremen protection with neutral pre-destruction timing. Neither is publisher clarification or universal reaction priority. Existing Richese/Guild contracts and complete-mode gates remain.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/NEXUS_GUILD_RULES.md'] }, + { area: 'Verification', status: 'Partial', detail: 'Protection related9-file union86/86 and actual390px GameTable pass. Genuine setup, worm/alliance/closing draw, paid safe Hagga shipment and free Advanced advisor produce the next real worm; Use/Pass, source rejection, four policies and original Great Maker vote/replacement are exercised. Neutral Continue leaves groups intact; Use preserves Emperor1/reserves19/Tanks0/spice10, devours BG advisor1, clears four spice, spends one Nexus and accepts the resumed Sihaya blow. No overflow/errors. Earlier Voice/Tleilaxu evidence remains bounded; wider modules, assurance and deployed acceptance stay open.', evidence: ['tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts', 'tests/nexus-borrowed-voice.test.ts', 'tests/fixture-nexus-borrowed-voice.ts', 'tests/nexus-borrowed-voice-runtime.test.ts', 'tests/nexus-fremen-protection.test.ts', 'tests/fixture-nexus-fremen-protection.ts', 'tests/nexus-fremen-protection-runtime.test.ts', 'tests/fixture-stronghold-factions.ts'] }, ], }, { diff --git a/game/richese-cancellation.ts b/game/richese-cancellation.ts index 10439c22..0ba1423c 100644 --- a/game/richese-cancellation.ts +++ b/game/richese-cancellation.ts @@ -89,6 +89,7 @@ const DECISIONS = { greatMakerRide: true, wormRide: true, nexusFremenCunningOffer: true, + nexusFremenProtection: true, nexusFremenCunningRide: true, moritaniRetention: true, moritaniSpecialKarama: true, diff --git a/tests/fixture-nexus-fremen-protection.ts b/tests/fixture-nexus-fremen-protection.ts new file mode 100644 index 00000000..d8159b4b --- /dev/null +++ b/tests/fixture-nexus-fremen-protection.ts @@ -0,0 +1,327 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer, + type Action, type Game, type Player, +} from '../game/engine'; +import { location, splitLocation, territory, validGameLocation } from '../game/board'; +import { spiceDeck, treacheryDeck, type SpiceCard } from '../game/cards'; +import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards'; +import { DISCOVERY_SPICE_CARDS } from '../game/discoveries'; +import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions'; + +/** One real native step: the actor is always the seat the engine expects. */ +export type NexusFremenProtectionStep = { actor: string; action: Action }; +export type NexusFremenProtectionFixture = { + /** Fresh classic lobby and its genuine initialized, undealt setup. */ + initial: Game; + afterSetup: Game; + /** Turn-two Shai-Hulud Nexus before the real reciprocal alliance. */ + beforeNexus: Game; + /** Real closing private-card opportunity for the two unallied seats. */ + beforeClosingDraw: Game; + afterClosingDraw: Game; + /** Turn-two Shipment and Movement turn of the unallied Nexus holder. */ + beforeShipment: Game; + afterShipment: Game; + /** Exact state before the native action that opens the boundary. */ + beforeWorm: Game; + producer: NexusFremenProtectionStep; + /** Genuine pre-destruction boundary: the frame is open, nothing is devoured. */ + game: Game; + advanced: boolean; + /** Unallied seat that really drew the physical Fremen Nexus card. */ + holder: string; + /** Unallied seat that holds an irrelevant Nexus face. */ + neutral: string; + /** The two seats that formed the real reciprocal alliance. */ + allies: [string, string]; + territory: string; + sector: number; + /** Where the holder's paid reserve force (and any advisor) stands. */ + location: string; + /** The undealt land card the original worm draws after devouring. */ + replacement: string; + actions: NexusFremenProtectionStep[]; + staging: string[]; + /** Raw JSON-safe facts for the parent's API and phone proof. */ + scenario: Record; +}; + +export function nexusFremenProtectionPlayer(game: Game, id: string): Player { + const player = game.players.find(candidate => candidate.id === id); + assert.ok(player, `Missing original classic seat ${id}`); + return player; +} +export const nexusFremenProtectionClean = (game: Game): boolean => + !game.phaseOpening && !game.response && !game.decision && !game.truthtrance && + !game.pendingTreacheryDiscard; + +/** Scalar shuffle lotteries preserve printed order; UUID entropy stays native. */ +function producerEntropy(run: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + const native = crypto.getRandomValues.bind(crypto); + crypto.getRandomValues = (array: V): V => { + if (array instanceof Uint32Array && array.length === 1) array[0] = 0xffffffff; + else Reflect.apply(native, crypto, [array]); + return array; + }; + try { return run(); } + finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } +} + +/** Original engine priority is settled before this fixture's own table choices: + * responses, private decisions, sealed battle plans and the Nexus card + * opportunity all run first. The fixture adds only two deterministic policies: + * the acknowledgement answer, and declining a free intrusion or advisor battle + * so no seat ever needs an invented sealed plan. */ +export function nextNexusFremenProtectionNativeStep(game: Game): NexusFremenProtectionStep | null { + if (game.status === 'finished') return null; + if (game.phaseOpening || game.response || game.truthtrance || game.pendingTreacheryDiscard) + return nextStrongholdFactionsNativeStep(game); + const decision = game.decision; + if (decision?.kind === 'nexusFremenProtection') + return { actor: decision.player, action: { + type: 'nexusFremenProtection', event: decision.event, use: false } }; + if (decision?.kind === 'advisor' || decision?.kind === 'intrusion' || + decision?.kind === 'advisorBattle') + return { actor: decision.player, action: { type: 'decision', accept: false } }; + if (decision) return nextStrongholdFactionsNativeStep(game); + const phase = game.nexusCards?.phase; + if (phase?.stage === 'drawing') { + const actor = phase.eligible.find(id => !phase.done.includes(id)); + assert.ok(actor, 'The Nexus draw window must retain an unfinished recipient'); + const held = game.nexusCards!.cards!.hands[actor]; + return { actor, action: { type: 'nexusCardChoice', turn: phase.turn, card: held, + choice: held ? 'keep' : 'draw', ownRedraws: 0 } }; + } + return nextStrongholdFactionsNativeStep(game); +} +export function applyNexusFremenProtectionStep(game: Game, step: NexusFremenProtectionStep, + actions?: NexusFremenProtectionStep[]): Game { + actions?.push(structuredClone(step)); + return producerEntropy(() => applyAction(game, step.actor, step.action)); +} +export function advanceNexusFremenProtection(game: Game, until: (game: Game) => boolean, + actions?: NexusFremenProtectionStep[]): Game { + for (let count = 0; count < 1800; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished', 'The original chronology ended early'); + const step = nextNexusFremenProtectionNativeStep(game); + assert.ok(step, `The original continuation needs a sealed plan at ${game.status}/${game.turn}/${game.phase}`); + game = applyNexusFremenProtectionStep(game, step, actions); + } + throw new Error('The original absent-Fremen chronology did not reach its boundary'); +} + +/** Physical census only: every original Treachery, Spice and Nexus card plus all + * twenty physical force counters per seat; no module owner or face is assumed. */ +export function nexusFremenProtectionInventory(game: Game): void { + const treachery = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand), + ...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])]; + assert.deepEqual(treachery.map(card => card.id).sort(), + treacheryDeck(game.expansions).map(card => card.id).sort()); + assert.equal(new Set(treachery.map(card => card.id)).size, treachery.length); + const spice = [...game.spiceDeck, ...game.spiceDiscard.flat(), + ...(game.spiceSequence?.skipped ?? [])]; + const printedSpice = [...spiceDeck(), ...(game.discoveryEnabled + ? [...DISCOVERY_SPICE_CARDS, { worm: true, greatMaker: true }] : [])]; + assert.deepEqual(spice.map(card => JSON.stringify(card)).sort(), + printedSpice.map(card => JSON.stringify(card)).sort()); + assert.ok(game.nexusCards?.cards); + validateNexusCards(game.nexusCards.cards, game.players); + assert.deepEqual([...game.nexusCards.cards.deck, ...game.nexusCards.cards.discard, + ...Object.values(game.nexusCards.cards.hands).filter(card => card !== null)].sort(), + [...NEXUS_FACTIONS].sort()); + for (const player of game.players) + assert.equal(player.reserves + player.tanks + + Object.values(player.forces).reduce((sum, amount) => sum + amount, 0), 20); +} +/** Public territory presence, including a concealed marker; physical groups stay in forces. */ +export function nexusFremenProtectionPresence(game: Game, id: string, site: string): number { + return Object.entries(nexusFremenProtectionPlayer(game, id).forces) + .filter(([key]) => splitLocation(key).territory === site) + .reduce((sum, [, amount]) => sum + amount, 0); +} + +/** Only an original undealt physical Spice Card is moved to the front of the + * physical deck; the census and the remaining printed order are untouched. */ +function orderUndealtSpice(game: Game, front: readonly string[]): void { + const take = (matches: (card: SpiceCard) => boolean): SpiceCard => { + const index = game.spiceDeck.findIndex(matches); + assert.ok(index >= 0, 'Only an original undealt physical Spice Card may be positioned'); + return game.spiceDeck.splice(index, 1)[0]; + }; + game.spiceDeck.unshift(...front.map(entry => entry === 'worm' || entry === 'greatMaker' + ? take(card => 'worm' in card && !!card.greatMaker === (entry === 'greatMaker') && + !card.thumper && !card.suppressed) + : take(card => 'territory' in card && card.territory === entry && !card.discovery))); +} +/** Only the original undealt Nexus deck is reordered; no hand is ever granted. */ +function orderUndealtNexusCards(game: Game): void { + const cards = game.nexusCards!.cards!; + const front = ['fremen', 'beneGesserit'] as const; + cards.deck = [...front, ...cards.deck.filter(card => !front.some(face => face === card))]; +} + +/** One fixed genuine Basic/Advanced absent-Fremen chronology with no candidate + * retries: real setup, a real empty turn-two worm, the real reciprocal alliance + * and closing draw that put the physical Fremen Nexus card into the unallied + * holder's hand, a real paid reserve shipment into a real sand source, and the + * next accepted real natural worm threatening exactly that group. Advanced adds + * the native free accompanied Bene Gesserit advisor at the same sector. */ +export function createNexusFremenProtectionFixture( + options: { advanced?: boolean; greatMaker?: boolean } = {}, +): NexusFremenProtectionFixture { + const advanced = options.advanced ?? false; + const holder = 'protection-emperor', neutral = 'protection-neutral'; + const allies: [string, string] = ['protection-guild', 'protection-atreides']; + const target = 'hagga_basin', sector = 12, replacement = 'sihaya_ridge'; + const destination = location(target, sector); + const actions: NexusFremenProtectionStep[] = []; + const staging = [ + 'Original scalar producer lotteries keep every printed shuffle order; native UUID entropy is untouched.', + 'Only the undealt physical Spice Deck is ordered: turn-one land, the real empty turn-two worm, the turn-two sand blow the group ships into, the next accepted real worm, then the replacement draw.', + 'Only the undealt physical Nexus Deck is ordered so the real closing draws give the unallied holder the physical Fremen card and the other unallied seat the physical Bene Gesserit card; no hand is assigned.', + 'Every storm dialer chooses the printed minimum (0 on turn one, otherwise 1), an original legal dial that keeps the real storm on Meridian at turn one and well clear of the shipped sand source afterwards', + advanced + ? 'The physically dealt Bene Gesserit seat documents the absent-Fremen case: it holds an irrelevant Nexus face and accepts the native free accompanied advisor arrival at the same sector.' + : 'No Bene Gesserit is seated; the fourth seat is Harkonnen and holds an irrelevant Nexus face at no other location.', + ]; + let game = createGame('NEXUSFREMENPROTECTION', newPlayer(holder, 'Emperor', 'emperor'), advanced); + joinGame(game, newPlayer(allies[0], 'Guild', 'guild')); + joinGame(game, newPlayer(allies[1], 'Atreides', 'atreides')); + joinGame(game, newPlayer(neutral, advanced ? 'Bene Gesserit' : 'Harkonnen', + advanced ? 'beneGesserit' : 'harkonnen')); + for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' }); + game.nexusCards = { cards: null, phase: null }; + if (options.greatMaker) game.discoveryEnabled = true; + game = producerEntropy(() => initializeNexusGameForAudit(game)); + const threat = options.greatMaker ? 'greatMaker' : 'worm'; + orderUndealtSpice(game, advanced + ? ['broken_land', 'rock_outcroppings', 'worm', target, 'south_mesa', threat, replacement, 'habbanya_erg'] + : ['broken_land', 'worm', target, threat, replacement]); + orderUndealtNexusCards(game); + const initial = structuredClone(game); + game = advanceNexusFremenProtection(game, state => state.status === 'playing', actions); + const afterSetup = structuredClone(game); + assert.ok(game.players.every(player => player.hand.length > 0 && player.traitors.length > 0), + 'The real setup must deal traitors and starting Treachery Cards'); + assert.ok(!game.players.some(player => player.faction === 'fremen'), + 'The native advantage must stay absent for this protection path'); + nexusFremenProtectionInventory(game); + + // Real turn-two Shai-Hulud appearance: an accepted natural worm opens the Nexus. + game = advanceNexusFremenProtection(game, state => state.turn === 2 && state.phase === 1 && + state.nexus === true && !state.spiceWindow && nexusFremenProtectionClean(state), actions); + const beforeNexus = structuredClone(game); + assert.equal(game.nexusCards?.phase?.turn, 2); + for (const [actor, partner] of [[allies[0], allies[1]], [allies[1], allies[0]]] as const) + game = applyNexusFremenProtectionStep(game, + { actor, action: { type: 'alliance', target: partner } }, actions); + assert.equal(nexusFremenProtectionPlayer(game, allies[0]).ally, allies[1]); + assert.equal(nexusFremenProtectionPlayer(game, allies[1]).ally, allies[0]); + assert.equal(nexusFremenProtectionPlayer(game, holder).ally, null); + assert.equal(nexusFremenProtectionPlayer(game, neutral).ally, null); + + // Real closing opportunity: both unallied seats choose from their own position. + game = advanceNexusFremenProtection(game, state => + state.nexusCards?.phase?.stage === 'drawing' && nexusFremenProtectionClean(state), actions); + const beforeClosingDraw = structuredClone(game); + assert.deepEqual([...game.nexusCards!.phase!.eligible].sort(), [holder, neutral].sort()); + assert.ok(nexusFremenProtectionPlayer(game, holder).hand.length > 0, + 'The real setup must deal the holder a starting Treachery Card'); + game = advanceNexusFremenProtection(game, + state => state.nexusCards?.phase?.stage === 'complete', actions); + const afterClosingDraw = structuredClone(game); + assert.equal(game.nexusCards!.cards!.hands[holder], 'fremen', + 'The real closing draw put the physical Fremen Nexus card into the unallied holder hand'); + assert.equal(game.nexusCards!.cards!.hands[neutral], 'beneGesserit'); + assert.ok(!game.nexusCards!.cards!.discard.includes('fremen')); + + // Real paid reserve shipment of one force into the sand source, followed in + // Advanced by the native free accompanied advisor arrival (never a battle). + game = advanceNexusFremenProtection(game, state => state.turn === 2 && state.phase === 5 && + state.active === holder && nexusFremenProtectionClean(state), actions); + const beforeShipment = structuredClone(game); + assert.equal(territory(target).type, 'sand'); + assert.ok(validGameLocation(game, target, sector)); + game = applyNexusFremenProtectionStep(game, { actor: holder, action: { + type: 'ship', territory: target, sector, amount: 1, elite: 0, allyPayment: 0 } }, actions); + if (advanced) { + game = advanceNexusFremenProtection(game, state => !state.phaseOpening && !state.response && + state.decision?.kind === 'advisor' && state.decision.player === neutral && + state.decision.destination === destination, actions); + game = applyNexusFremenProtectionStep(game, { actor: neutral, action: { + type: 'decision', accept: true, accompany: true, sector, amount: 1 } }, actions); + } + game = advanceNexusFremenProtection(game, nexusFremenProtectionClean, actions); + const afterShipment = structuredClone(game); + const shipped = nexusFremenProtectionPlayer(game, holder); + assert.equal(shipped.forces[destination], 1, 'The paid force must leave reserves for the sand source'); + assert.equal(shipped.reserves, + nexusFremenProtectionPlayer(beforeShipment, holder).reserves - 1); + assert.ok(shipped.spice < nexusFremenProtectionPlayer(beforeShipment, holder).spice, + 'The reserve shipment must be really paid from the holder’s own spice'); + assert.equal(nexusFremenProtectionPlayer(game, holder).ally, null); + if (advanced) { + const advisor = nexusFremenProtectionPlayer(game, neutral); + assert.equal(advisor.forces[destination], 1); + assert.ok(advisor.advisors?.[target], 'The native arrival is an advisor, not a fighter'); + assert.equal(advisor.reserves, + nexusFremenProtectionPlayer(beforeShipment, neutral).reserves - 1); + assert.equal(advisor.spice, nexusFremenProtectionPlayer(beforeShipment, neutral).spice); + } + + // Retain the exact native action that opens the new pre-destruction window. + let beforeWorm: Game | undefined, producer: NexusFremenProtectionStep | undefined; + for (let count = 0; count < 1800 && !beforeWorm; count++) { + const next = nextNexusFremenProtectionNativeStep(game); + assert.ok(next, `The next accepted natural worm needs a native continuation at ${game.turn}/${game.phase}`); + const before = structuredClone(game); + game = applyNexusFremenProtectionStep(game, next, actions); + if (game.decision?.kind === 'nexusFremenProtection') { beforeWorm = before; producer = next; } + else assert.ok(game.turn <= 3, 'The next accepted natural worm must open the protection window'); + } + assert.ok(beforeWorm && producer); + const boundary = game.decision, frame = game.nexusFremenProtection; + assert.ok(boundary?.kind === 'nexusFremenProtection', 'The real worm must open its protection decision'); + assert.ok(frame, 'The real worm must open its protection frame'); + assert.equal(game.turn, 3); + assert.equal(game.phase, 1); + if (options.greatMaker) assert.equal(game.greatMaker?.stage, 'worm'); + else assert.equal(game.nexus, true, 'The accepted natural worm keeps its actual Nexus occurrence'); + assert.equal(frame.territory, target); + assert.equal(frame.origin, 'natural'); + assert.equal(frame.pile, 0); + assert.equal(frame.protected, null); + assert.equal(nexusFremenProtectionPlayer(game, holder).forces[destination], 1); + assert.ok((game.spice[destination] ?? 0) > 0, 'Real collection must leave spice for the real worm to clear'); + assert.deepEqual(game.wormRides, []); + assert.ok(!game.nexusFremenCunningOffer); + nexusFremenProtectionInventory(game); + const scenario = { + code: game.code, advanced, turn: game.turn, phase: game.phase, + event: boundary.event, territory: target, sector, location: destination, + holder, neutral, allies, + currentActor: boundary.player, + queue: [...frame.order], + nexusCard: 'fremen', neutralNexusCard: 'beneGesserit', + origin: 'natural', pile: frame.pile, + producer: structuredClone(producer), + paid: nexusFremenProtectionPlayer(beforeShipment, holder).spice - + nexusFremenProtectionPlayer(afterShipment, holder).spice, + holdings: game.players.map(player => ({ id: player.id, faction: player.faction, + ally: player.ally, spice: player.spice, reserves: player.reserves, tanks: player.tanks, + forces: structuredClone(player.forces), + advisors: structuredClone(player.advisors ?? {}), + hand: player.hand.map(card => card.id) })), + staging: [...staging], + limitations: 'Fresh classic audit initializer plus an explicitly staged order of undealt physical cards and two native scalar lotteries; this is not a naturally shuffled or recovered played game, and every force, wallet, phase and alliance below arises from the real engine. The printed independent protection is provisional; no publisher answer is claimed.', + }; + return { initial, afterSetup, beforeNexus, beforeClosingDraw, afterClosingDraw, + beforeShipment, afterShipment, beforeWorm, producer, game, advanced, holder, neutral, allies, + territory: target, sector, location: destination, replacement, actions, staging, scenario }; +} diff --git a/tests/fixture-stronghold-factions.ts b/tests/fixture-stronghold-factions.ts index b41d7c08..29813f0d 100644 --- a/tests/fixture-stronghold-factions.ts +++ b/tests/fixture-stronghold-factions.ts @@ -91,6 +91,8 @@ export function nextStrongholdFactionsNativeStep(game: Game): StrongholdFactions const decision = game.decision; if (decision) { const actor = decision.player; + if (decision.kind === 'nexusFremenProtection') + return { actor, action: { type: 'nexusFremenProtection', event: decision.event, use: false } }; if (decision.kind === 'ixSetup') return { actor, action: { type: 'decision', card: game.ixSetupCards![0].id } }; if (decision.kind === 'mobileStronghold') return { actor, action: decision.placement ? { type: 'decision', location: 'polar_sink:0' } : { type: 'decision', decline: true } }; if (decision.kind === 'ixTechnology' || decision.kind === 'ixSubstitution' || decision.kind === 'captureOffer' || decision.kind === 'fullPlanOffer' || decision.kind === 'choamTradeReply') @@ -111,6 +113,9 @@ export function nextStrongholdFactionsNativeStep(game: Game): StrongholdFactions const actor = [game.battle.attacker, game.battle.defender].find(id => !game.battle!.preLeader!.ready.includes(id)); if (actor) return { actor, action: { type: 'battlePreparationReady', event: game.battle.event } }; } + if (game.battle?.preparation?.kind === 'nexusVoice') + return { actor: game.battle.preparation.owner, + action: { type: 'nexusBorrowedVoice', event: game.battle.nexusBorrowedVoice!.event, use: false } }; if (game.battle?.preparation) return { actor: game.battle.preparation.owner, action: { type: 'declineBattlePower' } }; if (game.battle?.revealed) { const actor = viewGame(game, game.host).battle!.traitorVoters.find(id => game.battle!.traitorCalls[id] === undefined); diff --git a/tests/nexus-fremen-protection-runtime.test.ts b/tests/nexus-fremen-protection-runtime.test.ts new file mode 100644 index 00000000..076fea90 --- /dev/null +++ b/tests/nexus-fremen-protection-runtime.test.ts @@ -0,0 +1,271 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { splitLocation } from '../game/board'; +import { + advanceNexusFremenProtection, applyNexusFremenProtectionStep, + createNexusFremenProtectionFixture, nexusFremenProtectionClean, + nexusFremenProtectionInventory, nexusFremenProtectionPlayer, + nexusFremenProtectionPresence, + type NexusFremenProtectionFixture, +} from './fixture-nexus-fremen-protection'; + +/** Only invalid consumers are tried; physical inventory must be untouched. */ +function reject(game: Game, actor: string, action: Action): void { + const before = structuredClone(game); + assert.throws(() => applyAction(game, actor, action)); + assert.deepEqual(game, before, 'Rejected declarations cannot spend the card, kill groups or advance the worm'); +} +/** Public holdings only; used to prove nothing resolves before the queue closes. */ +function holdings(game: Game): { id: string; forces: Record; spice: number; + reserves: number; tanks: number; ally: string | null }[] { + return game.players.map(player => ({ id: player.id, ally: player.ally, + forces: structuredClone(player.forces), spice: player.spice, + reserves: player.reserves, tanks: player.tanks })); +} +/** Exactly one owner may be protected; every other group in the territory is devoured. */ +function assertDevoured(pending: Game, resolved: Game, fixture: NexusFremenProtectionFixture, + protectedOwner: string | null): void { + for (const player of pending.players) { + const before = nexusFremenProtectionPlayer(pending, player.id); + const after = nexusFremenProtectionPlayer(resolved, player.id); + if (player.id === protectedOwner) { + assert.deepEqual(after.forces, before.forces, 'The protected owner keeps its exact physical groups'); + assert.equal(after.tanks, before.tanks); + assert.equal(after.reserves, before.reserves); + assert.equal(after.spice, before.spice, 'This printed protection invents no payment, revival or refund'); + continue; + } + const consumed = nexusFremenProtectionPresence(pending, player.id, fixture.territory); + assert.equal(nexusFremenProtectionPresence(resolved, player.id, fixture.territory), 0, + 'Every unprotected group in the worm territory is devoured'); + assert.equal(after.tanks, before.tanks + consumed); + assert.equal(after.reserves, before.reserves); + assert.equal(after.spice, before.spice); + } +} +function assertSpiceCleared(pending: Game, resolved: Game, fixture: NexusFremenProtectionFixture): void { + assert.ok(Object.entries(pending.spice).some(([key, amount]) => + splitLocation(key).territory === fixture.territory && amount > 0), + 'Real spice from the accepted sand blow awaited the worm'); + assert.ok(Object.keys(resolved.spice).every(key => + splitLocation(key).territory !== fixture.territory), + 'The original worm still clears every spice group in its territory'); +} + +/** Answers the whole original queue before the devouring boundary, asserting the + * private consumer contract at every acknowledgement. */ +function closeProtection(fixture: NexusFremenProtectionFixture, use: boolean): Game { + let game = structuredClone(fixture.game); + const frame = game.nexusFremenProtection; + assert.ok(frame); + const originalOrder = [...frame.order], event = frame.event; + const before = holdings(game); + let used = false; + for (const expected of originalOrder) { + const decision = game.decision; + assert.ok(decision?.kind === 'nexusFremenProtection'); + assert.equal(decision.event, event); + assert.equal(decision.player, expected, 'Neutral acknowledgements keep the original storm order'); + const offer = viewGame(game, expected).nexusFremenProtection; + assert.ok(offer, 'Only the current queue owner receives the private declaration surface'); + assert.equal(offer.owner, expected); + assert.equal(offer.event, event); + assert.equal(offer.territory, fixture.territory); + assert.equal(offer.blocked, null); + assert.equal(offer.canUse, expected === fixture.holder && !used, + 'Only the real unallied holder that has not spent its card may protect its own group'); + for (const other of game.players.filter(player => player.id !== expected)) + assert.equal(viewGame(game, other.id).nexusFremenProtection, null); + if (expected !== fixture.holder) + reject(game, expected, { type: 'nexusFremenProtection', event, use: true }); + const answer = expected === fixture.holder && use; + game = applyNexusFremenProtectionStep(game, { actor: expected, action: { + type: 'nexusFremenProtection', event, use: answer } }); + used ||= answer; + assert.deepEqual(game.nexusFremenProtection!.order, originalOrder); + assert.equal(game.nexusFremenProtection!.event, event); + assert.equal(game.nexusFremenProtection!.protected, used ? fixture.holder : null); + assert.equal(game.nexusCards!.cards!.hands[fixture.holder], used ? null : 'fremen'); + assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, used ? 1 : 0); + if (game.nexusFremenProtection!.stage === 'choose') + assert.deepEqual(holdings(game), before, + 'Even a declaration resolves nothing before the whole original queue closes'); + reject(game, expected, { type: 'nexusFremenProtection', event, use: answer }); + } + assert.equal(game.nexusFremenProtection!.stage, 'complete'); + assert.notEqual(game.decision?.kind, 'nexusFremenProtection'); + assert.deepEqual([...game.nexusFremenProtection!.passed], originalOrder); + assert.equal(viewGame(game, fixture.holder).nexusFremenProtection, null); + return game; +} +/** The real later Nexus/closing continuation, from the held card's own position. */ +function continueToLaterNexus(game: Game): Game { + return advanceNexusFremenProtection(game, state => state.turn === 3 && + state.nexusCards?.phase?.stage === 'complete' && nexusFremenProtectionClean(state)); +} + +for (const advanced of [false, true]) { + const label = advanced ? 'Advanced' : 'Basic'; + void test(`${label}: the real absent-Fremen Use protects only the shipped group before the devouring and spends one physical card`, () => { + const fixture = createNexusFremenProtectionFixture({ advanced }); + const pending = fixture.game, frame = pending.nexusFremenProtection; + assert.ok(frame); + assert.equal(frame.stage, 'choose'); + assert.equal(frame.protected, null); + assert.deepEqual(frame.order, pending.order.filter(id => + id === fixture.holder || (advanced && id === fixture.neutral)), + 'The public queue holds only storm-ordered unallied threatened seats that publicly hold a Nexus'); + assert.equal(nexusFremenProtectionPlayer(pending, fixture.holder).forces[fixture.location], 1); + assert.equal(pending.nexusCards!.cards!.hands[fixture.holder], 'fremen'); + assert.deepEqual(pending.spiceDiscard[0].at(-1), { worm: true }); + assert.equal(pending.nexus, true); + assert.deepEqual(pending.wormRides, []); + assert.ok(!pending.summonedWorm); + assert.ok(!pending.players.some(player => player.faction === 'fremen')); + nexusFremenProtectionInventory(pending); + + const resolved = closeProtection(fixture, true); + assert.equal(resolved.nexusFremenProtection!.stage, 'complete'); + assert.equal(resolved.nexusFremenProtection!.protected, fixture.holder); + assertDevoured(pending, resolved, fixture, fixture.holder); + assertSpiceCleared(pending, resolved, fixture); + assert.equal(resolved.spiceWindow?.territory, fixture.replacement, + 'afterWorm resumes the original next undealt spice card'); + assert.equal(resolved.spiceDeck.length, pending.spiceDeck.length - 1, + 'The replacement producer runs exactly once'); + assert.deepEqual(resolved.wormRides, [], 'This printed protection grants no worm riding'); + assert.ok(!resolved.nexusFremenCunningOffer && !resolved.nexusFremenCunningRides?.length); + assert.equal(resolved.nexusCards!.cards!.hands[fixture.holder], null); + assert.equal(resolved.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1); + assert.equal(nexusFremenProtectionPlayer(resolved, fixture.holder).faction, 'emperor', + 'Protection grants no native force identity'); + nexusFremenProtectionInventory(resolved); + + const closed = continueToLaterNexus(resolved); + assert.equal(closed.turn, 3); + assert.equal(closed.nexusCards!.phase!.stage, 'complete', + 'The later native Nexus and its private choices still close'); + assert.notEqual(closed.nexusCards!.cards!.hands[fixture.holder], 'fremen', + 'The spent singleton cannot be redrawn from the later real closing'); + assert.equal(closed.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1); + assert.equal(nexusFremenProtectionPlayer(closed, fixture.holder).forces[fixture.location], 1); + assert.equal(nexusFremenProtectionPlayer(closed, fixture.holder).tanks, + nexusFremenProtectionPlayer(pending, fixture.holder).tanks); + assert.deepEqual(closed.wormRides, []); + nexusFremenProtectionInventory(closed); + reject(closed, fixture.holder, { type: 'nexusFremenProtection', event: frame.event, use: true }); + }); + + void test(`${label}: Pass leaves the Fremen card held and the original worm sends the group to Tanks before the native closing`, () => { + const fixture = createNexusFremenProtectionFixture({ advanced }); + const pending = fixture.game; + const resolved = closeProtection(fixture, false); + assert.equal(resolved.nexusFremenProtection!.protected, null); + assertDevoured(pending, resolved, fixture, null); + assertSpiceCleared(pending, resolved, fixture); + assert.equal(resolved.nexusCards!.cards!.hands[fixture.holder], 'fremen', + 'A Pass keeps holding the physical Fremen card'); + assert.ok(!resolved.nexusCards!.cards!.discard.includes('fremen')); + assert.equal(resolved.spiceWindow?.territory, fixture.replacement); + assert.deepEqual(resolved.wormRides, []); + nexusFremenProtectionInventory(resolved); + const closed = continueToLaterNexus(resolved); + assert.equal(closed.nexusCards!.phase!.stage, 'complete'); + assert.equal(closed.nexusCards!.cards!.hands[fixture.holder], 'fremen'); + assert.ok(!closed.nexusCards!.cards!.discard.includes('fremen')); + assert.equal(nexusFremenProtectionPresence(closed, fixture.holder, fixture.territory), 0); + assert.equal(nexusFremenProtectionPlayer(closed, fixture.holder).tanks, + nexusFremenProtectionPlayer(pending, fixture.holder).tanks + 1); + assert.deepEqual(closed.wormRides, []); + nexusFremenProtectionInventory(closed); + }); + + void test(`${label}: wrong actor, stale event, extra keys, wrong schema and replay stay immutable at the boundary`, () => { + const fixture = createNexusFremenProtectionFixture({ advanced }); + const pending = fixture.game, decision = pending.decision; + assert.ok(decision?.kind === 'nexusFremenProtection'); + const actor = decision.player, event = decision.event; + const before = structuredClone(pending); + reject(pending, fixture.allies[0], { type: 'nexusFremenProtection', event, use: false }); + reject(pending, actor, { type: 'nexusFremenProtection', event: 'not-this-occurrence', use: false }); + reject(pending, actor, { type: 'nexusFremenProtection', event, use: false, territory: fixture.territory }); + reject(pending, actor, { type: 'nexusFremenProtection', event, use: false, owner: fixture.holder }); + reject(pending, actor, { type: 'nexusFremenProtection', event, use: false, karama: true }); + reject(pending, actor, { type: 'decision', event, accept: true }); + reject(pending, actor, { type: 'nexusFremenProtection', event }); + reject(pending, actor, { type: 'nexusFremenProtection', use: false }); + const action: Action = { type: 'nexusFremenProtection', event, use: false }; + const answered = applyNexusFremenProtectionStep(pending, { actor, action }); + reject(answered, actor, action); + if (answered.decision?.kind === 'nexusFremenProtection') { + assert.equal(answered.decision.event, event); + assert.notEqual(answered.decision.player, actor); + assert.equal(nexusFremenProtectionPlayer(answered, fixture.holder).forces[fixture.location], 1); + assert.equal(nexusFremenProtectionPlayer(answered, fixture.neutral).forces[fixture.location], 1); + assert.deepEqual(holdings(answered), holdings(before), + 'No acknowledgement may resolve a group before the queue closes'); + } + nexusFremenProtectionInventory(answered); + }); + + void test(`${label}: all four legal bot profiles answer only their real queue owner at this boundary`, () => { + const fixture = createNexusFremenProtectionFixture({ advanced }); + for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) { + let game = structuredClone(fixture.game); + const before = structuredClone(game); + for (const expected of game.nexusFremenProtection!.order) { + const decision = game.decision; + assert.ok(decision?.kind === 'nexusFremenProtection'); + assert.equal(decision.player, expected); + const view = viewGame(game, expected); + view.players.find(player => player.id === expected)!.bot = profile; + const snapshot = structuredClone(view); + const action = botActions(view).find(candidate => candidate.type === 'nexusFremenProtection'); + assert.ok(action, `${profile} answers the real worm protection acknowledgement`); + assert.deepEqual(view, snapshot, 'Bot policy never mutates its own private projection'); + assert.equal(action.event, decision.event); + assert.equal(action.use, expected === fixture.holder, + 'The irrelevant Nexus face passes while the actual holder protects its own group'); + const wrong = viewGame(game, fixture.allies[0]); + wrong.players.find(player => player.id === fixture.allies[0])!.bot = profile; + assert.ok(botActions(wrong).every(candidate => candidate.type !== 'nexusFremenProtection'), + 'A seat with no current acknowledgement receives no unsolicited impossible prompt'); + game = applyNexusFremenProtectionStep(game, { actor: expected, action }); + nexusFremenProtectionInventory(game); + } + assert.equal(game.nexusFremenProtection!.stage, 'complete'); + assert.equal(game.nexusFremenProtection!.protected, fixture.holder); + assertDevoured(before, game, fixture, fixture.holder); + assert.equal(game.nexusCards!.cards!.hands[fixture.holder], null); + assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1); + assert.deepEqual(game.wormRides, []); + } + }); +} + +for (const advanced of [false, true]) { + void test(`${advanced ? 'Advanced' : 'Basic'}: protection keeps the original Great Maker vote before the replacement blow`, () => { + const fixture = createNexusFremenProtectionFixture({ advanced, greatMaker: true }); + const pending = fixture.game; + assert.equal(pending.greatMaker?.stage, 'worm'); + assert.deepEqual(pending.spiceDiscard[0].at(-1), { worm: true, greatMaker: true }); + const resolved = closeProtection(fixture, true); + assertDevoured(pending, resolved, fixture, fixture.holder); + assertSpiceCleared(pending, resolved, fixture); + assert.equal(resolved.greatMaker?.stage, 'vote'); + assert.equal(resolved.greatMaker?.event, pending.greatMaker?.event); + assert.equal(resolved.decision?.kind, 'greatMakerVote'); + assert.equal(resolved.spiceDeck.length, pending.spiceDeck.length, + 'The original vote must finish before drawing another physical spice card'); + const voted = advanceNexusFremenProtection(resolved, game => + game.greatMaker?.stage === 'complete' && game.spiceWindow?.territory === fixture.replacement && + nexusFremenProtectionClean(game)); + assert.equal(voted.spiceDeck.length, pending.spiceDeck.length - 1); + assert.deepEqual(voted.wormRides, []); + assert.equal(nexusFremenProtectionPlayer(voted, fixture.holder).forces[fixture.location], 1); + assert.equal(voted.nexusCards!.cards!.hands[fixture.holder], null); + nexusFremenProtectionInventory(voted); + }); +} diff --git a/tests/nexus-fremen-protection.test.ts b/tests/nexus-fremen-protection.test.ts new file mode 100644 index 00000000..ac313379 --- /dev/null +++ b/tests/nexus-fremen-protection.test.ts @@ -0,0 +1,302 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + answerNexusFremenProtection, + createNexusFremenProtection, + currentNexusFremenProtectionOwner, + validateNexusFremenProtection, + type NexusFremenProtectionFrame, + type NexusFremenProtectionInput, + type NexusFremenProtectionPlayer, +} from '../game/nexus-fremen-protection'; + +const sparseOrder: string[] = []; +sparseOrder.length = 3; + +/** Structural public data for the pure module, not a staged engine game. */ +function occurrence(): NexusFremenProtectionInput { + return { + event: 'd6d3fb17-14e4-4a31-a717-c041a9b02d7c', + turn: 3, + territory: 'hagga_basin', + origin: 'natural', + pile: 0, + index: 4, + order: ['h', 'r', 'a'], + players: [ + { id: 'a', faction: 'atreides', hasNexus: true, forces: { 'hagga_basin:12': 2 } }, + { id: 'h', faction: 'harkonnen', hasNexus: true, forces: { 'hagga_basin:13': 1 } }, + { + id: 'r', faction: 'richese', hasNexus: true, forces: {}, + noField: { deployed: { location: { territory: 'hagga_basin', sector: 12 } } }, + }, + ], + }; +} + +function created(input = occurrence()): NexusFremenProtectionFrame { + const frame = createNexusFremenProtection(input); + assert.ok(frame); + return frame; +} + +void test('public storm order includes only unallied threatened seats holding any Nexus', () => { + const input = occurrence(); + const players: NexusFremenProtectionPlayer[] = [ + input.players[0], + { ...input.players[1], ally: 'e' }, + input.players[2], + { id: 'c', faction: 'choam', hasNexus: false, forces: { 'hagga_basin:12': 8 } }, + { id: 'e', faction: 'emperor', ally: 'h', hasNexus: true, forces: {} }, + { id: 'g', faction: 'guild', hasNexus: true, forces: { 'carthag:11': 3 } }, + ]; + const publicInput = { ...input, players, order: ['r', 'g', 'h', 'c', 'a', 'e'] }; + const frame = created(publicInput); + assert.deepEqual(frame, { + event: input.event, turn: 3, territory: 'hagga_basin', origin: 'natural', + pile: 0, index: 4, stage: 'choose', order: ['r', 'a'], passed: [], protected: null, + }); + assert.equal(currentNexusFremenProtectionOwner(frame), 'r'); + validateNexusFremenProtection(publicInput, frame); + assert.equal(createNexusFremenProtection({ + ...publicInput, + players: players.map((player) => ({ ...player, hasNexus: false })), + }), null); + assert.equal(createNexusFremenProtection({ + ...publicInput, + players: players.map((player) => ({ ...player, forces: {}, noField: { deployed: null } })), + }), null); + assert.equal(createNexusFremenProtection({ + ...publicInput, + players: players.map((player) => player.id === 'g' ? { ...player, faction: 'fremen' } : player), + }), null, 'native Fremen suppresses this independent absent-Fremen queue even away from the worm'); +}); + +void test('unrelated dynamic-board groups do not block protection of the actual printed worm territory', () => { + const input = occurrence(); + const players = input.players.map(player => player.id === 'a' ? { + ...player, forces: { ...player.forces, 'hidden_mobile_stronghold:0': 2 }, + noField: { deployed: { location: { territory: 'hidden_mobile_stronghold', sector: 0 } } }, + } : player); + let frame = created({ ...input, players }); + frame = answerNexusFremenProtection(frame, 'h', true); + frame = answerNexusFremenProtection(frame, 'r', false); + frame = answerNexusFremenProtection(frame, 'a', false); + assert.equal(frame.protected, 'h'); + assert.equal(frame.stage, 'complete'); + validateNexusFremenProtection({ ...input, players }, frame); +}); + +void test('irrelevant faces and hidden No-Field contents are not read at any public transition', () => { + const input = occurrence(); + const hiddenRead = () => { throw new Error('private material was read'); }; + for (const player of input.players) { + Object.defineProperties(player, { + card: { enumerable: true, get: hiddenRead }, + hand: { enumerable: true, get: hiddenRead }, + nexusFace: { enumerable: true, get: hiddenRead }, + tokens: { enumerable: true, get: hiddenRead }, + reserves: { enumerable: true, get: hiddenRead }, + }); + } + const marker = input.players[2].noField!; + Object.defineProperty(marker, 'tokens', { enumerable: true, get: hiddenRead }); + Object.defineProperties(marker.deployed!, { + value: { enumerable: true, get: hiddenRead }, + token: { enumerable: true, get: hiddenRead }, + }); + let frame = created(input); + assert.deepEqual(frame.order, ['h', 'r', 'a']); + frame = answerNexusFremenProtection(frame, 'h', false); + assert.equal(currentNexusFremenProtectionOwner(frame), 'r'); + frame = answerNexusFremenProtection(frame, 'r', true); + assert.equal(frame.protected, 'r'); + assert.equal(currentNexusFremenProtectionOwner(frame), 'a'); + frame = answerNexusFremenProtection(frame, 'a', false); + assert.equal(frame.stage, 'complete'); + validateNexusFremenProtection(input, frame); + assert.equal(marker.deployed!.location.territory, 'hagga_basin'); +}); + +void test('Use protects just its original actor and leaves every remaining acknowledgement in order', () => { + const input = occurrence(); + const start = created(input); + const first = answerNexusFremenProtection(start, 'h', true); + assert.equal(first.protected, 'h'); + assert.deepEqual(first.order, ['h', 'r', 'a']); + assert.deepEqual(first.passed, ['h']); + assert.equal(first.stage, 'choose'); + assert.equal(currentNexusFremenProtectionOwner(first), 'r'); + assert.throws(() => answerNexusFremenProtection(first, 'r', true), /already has its protected owner/); + assert.deepEqual(first.passed, ['h'], 'a rejected second use cannot advance the queue'); + const second = answerNexusFremenProtection(first, 'r', false); + assert.equal(currentNexusFremenProtectionOwner(second), 'a'); + const complete = answerNexusFremenProtection(second, 'a', false); + assert.equal(complete.protected, 'h'); + assert.deepEqual(complete.passed, ['h', 'r', 'a']); + assert.equal(complete.stage, 'complete'); + assert.equal(currentNexusFremenProtectionOwner(complete), null); + validateNexusFremenProtection(input, complete); + assert.deepEqual(start.passed, []); + assert.equal(start.protected, null); +}); + +void test('wrong actor, repeated answer and nonboolean use cannot consume an acknowledgement', () => { + const start = created(); + for (const actor of ['a', 'r', 'missing', '']) + assert.throws(() => answerNexusFremenProtection(start, actor, false), /current original owner/); + assert.throws( + () => answerNexusFremenProtection(start, 'h', 1 as unknown as boolean), + /Use or Pass/, + ); + const next = answerNexusFremenProtection(start, 'h', false); + assert.throws(() => answerNexusFremenProtection(next, 'h', false), /current original owner/); + assert.equal(currentNexusFremenProtectionOwner(next), 'r'); + assert.deepEqual(start.passed, []); + assert.equal(start.stage, 'choose'); +}); + +void test('all passes close the queue only at the final original owner and completed frames cannot reopen', () => { + const input = occurrence(); + let frame = created(input); + for (const [index, actor] of frame.order.entries()) { + assert.equal(currentNexusFremenProtectionOwner(frame), actor); + frame = answerNexusFremenProtection(frame, actor, false); + assert.equal(frame.stage, index === 2 ? 'complete' : 'choose'); + } + assert.equal(frame.protected, null); + assert.equal(currentNexusFremenProtectionOwner(frame), null); + validateNexusFremenProtection(input, frame); + for (const actor of input.order) { + assert.throws(() => answerNexusFremenProtection(frame, actor, false), /current original owner/); + assert.throws(() => answerNexusFremenProtection(frame, actor, true), /current original owner/); + } +}); + +void test('one eligible original owner can close with Use without needing an impossible extra prompt', () => { + const input = occurrence(); + const alone = { + ...input, + players: input.players.map((player) => ({ ...player, hasNexus: player.id === 'r' })), + }; + const frame = answerNexusFremenProtection(created(alone), 'r', true); + assert.equal(frame.stage, 'complete'); + assert.equal(frame.protected, 'r'); + assert.deepEqual(frame.order, ['r']); + assert.deepEqual(frame.passed, ['r']); + assert.equal(currentNexusFremenProtectionOwner(frame), null); + validateNexusFremenProtection(alone, frame); +}); + +void test('source event, turn, territory, origin, pile and original discard index cannot be retargeted', () => { + const input = occurrence(); + const frame = created(input); + const changed: Partial[] = [ + { event: 'another-worm-at-the-same-place' }, { turn: 4 }, + { territory: 'the_great_flat' }, { origin: 'additional' }, { pile: 1 }, { index: 5 }, + ]; + for (const replacement of changed) + assert.throws(() => validateNexusFremenProtection({ ...input, ...replacement }, frame), /original occurrence identity/); + assert.throws(() => validateNexusFremenProtection({ ...input, order: ['a', 'h', 'r'] }, frame), /original public candidates/); + assert.throws(() => validateNexusFremenProtection({ + ...input, players: input.players.map((player) => player.id === 'h' ? { ...player, hasNexus: false } : player), + }, frame), /original public candidates/); + assert.throws(() => validateNexusFremenProtection({ + ...input, players: input.players.map((player) => ({ ...player, forces: {}, noField: null })), + }, frame), /original public candidates/, 'post-devour presence cannot retrofit a queue'); +}); + +void test('additional and summoned worms bind their own source, and printed rock spice territories are valid', () => { + const { pile: _pile, index: _index, ...base } = occurrence(); + const additional = { ...base, origin: 'additional' as const }; + const additionalFrame = created(additional); + assert.equal(Object.hasOwn(additionalFrame, 'pile'), false); + assert.equal(Object.hasOwn(additionalFrame, 'index'), false); + validateNexusFremenProtection(additional, additionalFrame); + const summoned = { ...base, origin: 'summoned' as const, parent: 'special-karama-original' }; + const summonedFrame = created(summoned); + validateNexusFremenProtection(summoned, summonedFrame); + assert.throws(() => validateNexusFremenProtection({ ...summoned, parent: 'different-karama' }, summonedFrame), /original occurrence identity/); + const rock = { + ...occurrence(), territory: 'rock_outcroppings', + players: base.players.map((player) => ({ ...player, forces: { 'rock_outcroppings:13': 1 }, noField: null })), + }; + assert.deepEqual(created(rock).order, ['h', 'r', 'a']); +}); + +void test('creation rejects malformed public source and order rather than silently shrinking the queue', () => { + const input = occurrence(); + const malformed: Record[] = [ + { event: '' }, { event: ' ' }, { turn: 0 }, { turn: -1 }, { turn: 1.5 }, + { turn: Number.MAX_SAFE_INTEGER + 1 }, { territory: 'not-a-territory' }, + { territory: 'arrakeen' }, { territory: 'hidden_mobile_stronghold' }, { origin: 'revived' }, + { pile: undefined }, { pile: 2 }, { index: undefined }, { index: -1 }, + { index: 1.5 }, { index: NaN }, { index: Number.MAX_SAFE_INTEGER + 1 }, + { parent: 'unrelated-parent' }, { origin: 'summoned', pile: undefined, index: undefined }, + { origin: 'summoned', parent: 'karama', pile: 0, index: undefined }, + { origin: 'summoned', parent: 'karama', pile: undefined, index: 0 }, + { origin: 'additional', parent: 'karama' }, + { order: ['h', 'r'] }, { order: ['h', 'r', 'r'] }, { order: ['h', 'r', 'unknown'] }, + { order: ['h', 'r', ''] }, { order: sparseOrder }, { players: null }, + ]; + for (const replacement of malformed) + assert.throws(() => createNexusFremenProtection({ ...input, ...replacement } as NexusFremenProtectionInput)); + const malformedSeats: Record[] = [ + { id: '' }, { id: 'h' }, { faction: '' }, { hasNexus: 'yes' }, + { ally: 'unknown' }, { ally: 'a' }, { forces: null }, + { forces: { 'hagga_basin:12': -1 } }, { forces: { 'hagga_basin:12': 1.5 } }, + { forces: { 'hagga_basin:99': 1 } }, { forces: { 'hagga_basin:012': 1 } }, + { noField: { deployed: {} } }, + { noField: { deployed: { location: { territory: 'hagga_basin', sector: 99 } } } }, + ]; + for (const replacement of malformedSeats) + assert.throws(() => createNexusFremenProtection({ + ...input, players: [{ ...input.players[0], ...replacement } as NexusFremenProtectionPlayer, ...input.players.slice(1)], + })); +}); + +void test('saved frames require exact fields, a prefix of original acknowledgements and one acknowledged protected owner', () => { + const input = occurrence(); + const frame = created(input); + const malformed: Record[] = [ + { extra: true }, { parent: undefined }, { stage: 'devoured' }, { stage: 'complete' }, + { order: [] }, { order: ['h', 'r', 'r'] }, { order: sparseOrder }, + { passed: ['r'] }, { passed: ['h', 'a'] }, { passed: ['h', 'h'] }, + { passed: ['h', 'r', 'a', 'unknown'] }, + { passed: ['h', 'r', 'a'], stage: 'choose' }, + { protected: 'h' }, { passed: ['h'], protected: 'r' }, + { passed: ['h'], protected: 'unknown' }, { passed: ['h', 'r'], protected: ['h', 'r'] }, + ]; + for (const replacement of malformed) { + const invalid = { ...frame, ...replacement } as NexusFremenProtectionFrame; + assert.throws(() => validateNexusFremenProtection(input, invalid)); + assert.throws(() => currentNexusFremenProtectionOwner(invalid)); + assert.throws(() => answerNexusFremenProtection(invalid, 'h', false)); + } + assert.throws(() => validateNexusFremenProtection(input, { ...frame, order: ['r', 'h', 'a'] }), /original public candidates/); + assert.throws(() => validateNexusFremenProtection(input, { ...frame, order: ['h', 'r'] }), /original public candidates/); +}); + +void test('frozen public inputs and frames remain untouched and retain independent original queue arrays', () => { + const input = occurrence(); + Object.freeze(input.order); + for (const player of input.players) { + Object.freeze(player.forces); + if (player.noField?.deployed) { + Object.freeze(player.noField.deployed.location); + Object.freeze(player.noField.deployed); + Object.freeze(player.noField); + } + Object.freeze(player); + } + Object.freeze(input.players); + Object.freeze(input); + const frame = created(input); + Object.freeze(frame.order); + Object.freeze(frame.passed); + Object.freeze(frame); + const next = answerNexusFremenProtection(frame, 'h', false); + assert.deepEqual(frame.passed, []); + assert.deepEqual(next.passed, ['h']); + validateNexusFremenProtection(input, next); +});