diff --git a/components/grumman-collection.tsx b/components/grumman-collection.tsx index 31f30f21..e00bbc1b 100644 --- a/components/grumman-collection.tsx +++ b/components/grumman-collection.tsx @@ -31,79 +31,92 @@ export function GrummanCollection({ game, act, busy }: Props) { function CollectionChoice({ game, act, busy }: Props) { const offer = game.grummanCollection!; - const [token, setToken] = useState(offer.tokens[0]?.id ?? ''); - const [destination, setDestination] = useState( - offer.destinations[0]?.id ?? '', + const initialMode = offer.blocked && !offer.removeBlocked ? 'remove' : 'add'; + const [mode, setMode] = useState<'add' | 'remove'>(initialMode); + const [token, setToken] = useState( + (initialMode === 'remove' ? offer.removable : offer.tokens)[0]?.id ?? '', ); + const [destination, setDestination] = useState(offer.destinations[0]?.id ?? ''); + const [useCunning, setUseCunning] = useState(false); const canAct = grummanCollectionCanAct(game); - const choice = grummanCollectionChoice(game, token, destination); - const disabled = busy || !canAct || !!offer.blocked; + const tokens = mode === 'remove' ? offer.removable : offer.tokens; + const destinations = useCunning && offer.cunning ? offer.cunning.destinations : offer.destinations; + const choice = grummanCollectionChoice(game, token, mode === 'add' ? destination : '', mode, useCunning); + const disabled = busy || !canAct; return (

- Add one available Terror token to a stronghold to collect 4 spice. You - receive the spice only after the addition resolves. + Add one available Terror token or return one unrevealed placed token to + hidden supply to collect 4 spice. Only one completed operation pays this + Collection; your separate Mentat placement remains unchanged.

- {choice.blocked && ( -

- {choice.blocked} -

- )} -

Removal unavailable: {offer.removeBlocked}

+ {mode === 'add' && <> + {offer.cunning && } + + {offer.cunning &&

+ Cunning requires an ordinary eligible supply addition at a stronghold + already holding Terror. It spends the Nexus card, not your Mentat + placement, and never permits relocation, Homeworlds or the Hidden + Mobile Stronghold. +

} + } + {mode === 'remove' &&

+ The token stays unrevealed and becomes reusable in hidden supply. + Hidden supply identities rotate; other placed tokens remain where they are. +

} + {choice.blocked &&

{choice.blocked}

}
- -
diff --git a/components/moritani-terror.tsx b/components/moritani-terror.tsx index 41f618a3..fd340f30 100644 --- a/components/moritani-terror.tsx +++ b/components/moritani-terror.tsx @@ -324,7 +324,7 @@ export function MoritaniPlacement({ onChange={(event) => setUseNexus(event.target.checked)} /> Use Moritani Nexus Cunning -

Cunning places one available supply token in an Arrakis territory, including one with Terror already present. It spends the Nexus card and this Mentat placement opportunity. Relocation, Grumman use, Homeworlds and the Hidden Mobile Stronghold are unavailable.

+

Cunning places one available supply token in an Arrakis territory, including one with Terror already present. It spends the Nexus card and this Mentat placement opportunity. It never enhances relocation or permits Homeworlds or the Hidden Mobile Stronghold. High-Grumman Cunning additions use their separate Collection controls and require an ordinary eligible addition.

{game.nexusMoritani.blocked &&

{game.nexusMoritani.blocked}

} }
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 86b665f4..a28ae019 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -1,6 +1,6 @@ # Current development status -Updated 13 October 2026. **The full twelve-faction goal is unfinished. Basic +Updated 14 October 2026. **The full twelve-faction goal is unfinished. Basic is playable and the six classic factions have a user-authorized Advanced preview. Expansion starts and full rules/publication acceptance remain gated.** @@ -29,6 +29,27 @@ optional skill bands, combinations and public expansion starts remain open. Rules implementation continues; comprehensive assurance and administration remain deferred until all rules are implemented. +**Ruled Grumman removal and Cunning addition — 14 October.** +The user selected supply-only Cunning, HMS exclusion, preserved Grumman +addition prerequisite and unrevealed return to hidden supply. Native removal +now rotates supply IDs and pays4 once without revealing the face or spending +Mentat placement. High-Grumman Cunning consumes one actual Nexus, binds its +phase7 source/prerequisite and permits a static Arrakis destination only when +an ordinary stacked addition was already eligible. No new cancellation window. +Genuine Basic/Advanced before/after programs preserve all original stock; +later real Guild entry at Polar Sink recognizes the Cunning placement, and its +later Grumman removal retires only that live location. No prerequisite means +no Cunning spend or reward. All four existing policies apply legal choices. +Actual390px return and Red Chasm Cunning addition each pay12→16, preserve +Mentat usage1 and close the original choice. Browser errors empty, no root +overflow; `/tmp/dune-ruled-grumman-ui-proof.json` records local proof. +[Cunning rulings](NEXUS_MORITANI_RULES.md#user-rulings--14-october-2026) +and [removal ruling](GRUMMAN_STACKED_TERROR_RULES.md#user-ruling--14-october-2026) +are interpretations selected by the user, not new publisher answers. +No complete-mode, broad-assurance or deployed acceptance claim follows. +Final affected14-file union92/92, types/lint and app build pass with the +existing module-registration, chunk-size and route-classification warnings. + **Documented Skills CLI validation corrected — 13 October.** The actual CLI still rejected `guild-betrayal --leader-skills` with `--leader-skills requires --profile semuta`, despite the already-connected diff --git a/docs/GRUMMAN_STACKED_TERROR_RULES.md b/docs/GRUMMAN_STACKED_TERROR_RULES.md index c9c32de4..38118f6e 100644 --- a/docs/GRUMMAN_STACKED_TERROR_RULES.md +++ b/docs/GRUMMAN_STACKED_TERROR_RULES.md @@ -1,6 +1,39 @@ # Grumman Collection and stacked Terror -Source audit, 10 September 2026. This supplements [HOMEWORLD_COLLECTION_RULES.md](HOMEWORLD_COLLECTION_RULES.md). It does not certify runtime integration or change release gates. The existing user question about unrevealed removal custody remains pending; this audit asks no duplicate question. +Source audit, 10 September 2026. This supplements [HOMEWORLD_COLLECTION_RULES.md](HOMEWORLD_COLLECTION_RULES.md). It does not certify runtime integration or change release gates. The formerly pending removal-custody question is resolved by the user ruling below, not a new publisher clarification. + +## User ruling — 14 October 2026 + +An unrevealed Grumman removal returns the selected placed Terror token to +hidden supply without revealing its face. Reuse original hidden-supply ID +rotation so its former public ID cannot track the returned face. Other placed +tokens and all six physical faces remain intact. + +One completed add-or-remove operation earns four spice, once per Collection; +declining earns none. Native Mentat placement usage remains independent. +Available, spent, revealed Extortion and Atomics Aftermath are not placed +Terror tokens eligible for this removal. +The user separately selected the +[Cunning underlying-addition prerequisite](NEXUS_MORITANI_RULES.md#user-rulings--14-october-2026). +These are selected interpretations, not publisher rulings or mode certification. + +## Runtime follow-through — 14 October 2026 + +The live Grumman quote now separately offers placed-token return even when no +available supply token exists for addition. The original hidden return rotates +only available-supply identities, preserves other placements/spent faces and +leaves native Mentat use unchanged. Historical completion retains the former +placed ID/location without treating that ID as live supply. + +The event-bound add/return selector, owner-only faces and shared legal policy +connect to the original Collection frame. Current high population, target +custody and one-use checks precede random supply rotation and four-spice credit. +Actual390px return pays12→16, clears the placement and keeps the returned face +hidden/reusable; new native regressions prove its later ordinary Mentat reuse. +[Native return evidence](../tests/grumman-supply-return.test.ts) and +[Cunning source/entry/return](../tests/grumman-cunning-runtime.test.ts) +cover the ruled paths. No complete-mode or deployed acceptance is claimed. + ## Evidence and its limits @@ -27,13 +60,18 @@ The chosen token must remain bound to the original entry through Enemy of My Ene Do not describe that serializer as printed priority. Revalidate current native population when the Grumman action is accepted. An allowed intervening effect that changes population, token custody or the phase must use the same continuation checks; a future noncommuting interaction would require review. Ecaz settlement cannot consume Grumman’s opportunity, and Grumman cannot overwrite an unresolved allocation. Its award is not desert collection and must not create a shared Ecaz allocation or Giedi collection trigger. -## Unresolved removal custody +## Source ambiguity and selected removal custody Removal from the board is not revelation. The revelation disposal rule does not establish that an unrevealed Grumman removal is permanently spent. Conversely, other effects’ express return-to-supply instructions do not prove this removal returns to hidden supply. Neither result was authenticated by this audit. -Keep that operation guarded pending the existing question. Do not reveal its face, call the revelation handler, rotate it into reusable supply, or credit four spice before its destination is resolved. Addition can be implemented independently using an actual available token and a stronghold already containing Terror. It does not authorize Homeworlds, the Hidden Mobile Stronghold, an invented token or movement from another stack. +The original guard was required while custody was unresolved. The selected +user interpretation above replaces it with a hidden-supply return and four- +spice award after current population, placed custody and unused Collection +opportunity are checked. Do not reveal the face or invoke the revelation +handler. Addition remains an independent supply placement, with its original +prerequisite unless modified through the selected Cunning composition. -## Concrete code review findings +## Original code review findings (historical) At the inspected checkpoint, `openTerrorEntry` in [engine.ts](../game/engine.ts) selects the first placed token at the destination. Stacking requires an owner-visible candidate choice, with hidden faces excluded from other seats, and a durable selected identity afterward. diff --git a/docs/HOMEWORLD_COLLECTION_RULES.md b/docs/HOMEWORLD_COLLECTION_RULES.md index 4942757f..84b7357a 100644 --- a/docs/HOMEWORLD_COLLECTION_RULES.md +++ b/docs/HOMEWORLD_COLLECTION_RULES.md @@ -34,9 +34,23 @@ The singular phase-scoped opportunity supports one selected operation and one aw For addition, select one available physical Terror token and a permitted stronghold already holding a placed token. The card supplies an exception to ordinary Terrorize's empty-destination requirement; it does not authorize a new token, a second ordinary Mentat placement, relocation from another stronghold, Homeworld placement or use of the Hidden Mobile Stronghold. Storm alone does not prevent ordinary Terror placement. [GF9 E3, pp.5,16](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=5). -For removal, select one actual placed token. A spent token, the separate Atomics Aftermath marker, an available supply token or a revealed Extortion record is not a Terror token on the board. The face says removal from the board; it does not expressly name its next custody zone. Returning it to hidden supply is a plausible composition, while ordinary **revelation** expressly removes tokens from the game. Do not claim the latter as authority to permanently spend an unrevealed removal, or call `revealTerror` merely to clear its location. The removal destination remains a source boundary for the coordinator; no new question was sent by this audit. +The [selected Cunning composition](NEXUS_MORITANI_RULES.md#user-rulings--14-october-2026) +preserves that ordinary addition prerequisite: an eligible stacked addition +must already exist before Cunning broadens its selected destination to static +Arrakis. It never grants relocation, a Homeworld or HMS destination. -Token change and four-spice credit must be one accepted transaction after checking current high population, valid custody, phase and unused opportunity. Reject before mutation or randomness if any check fails. Preserve the six physical identities and every other placed token. If returning a token to hidden supply is adopted from sufficient authority, use the established identity-rotation contract so a previously known face cannot be followed through a persistent public ID. +For removal, select one actual placed token. A spent token, Atomics Aftermath, +an available supply token or a revealed Extortion record is not eligible. +The physical face does not name the next custody zone; the +[14 October user ruling](GRUMMAN_STACKED_TERROR_RULES.md#user-ruling--14-october-2026) +selects hidden supply without revelation. Do not call `revealTerror` or +permanently retire the token to implement this removal. + +Token change and four-spice credit are one accepted transaction after current +high population, placed custody, phase and unused Collection opportunity are +checked. Reject before mutation or randomness. Preserve all six physical faces, +other placed tokens and native Mentat usage. Rotate hidden-supply IDs using the +original return contract so a former public ID cannot track the returned face. Stacking is a genuine new registry condition. Remove only the global one-token-per-location assumption; retain unique physical identity, exactly one location/status per token and valid stronghold locations. Ordinary Mentat placement still requires an empty destination. Never overwrite an existing token merely because both share a location. diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index e9fad156..65ade994 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,56 @@ # Dune implementation status +## 14 October 2026 — ruled Grumman hidden return and Cunning addition + +Four user decisions resolve the earlier Moritani boundaries: Cunning enhances +supply placement only; HMS stays excluded; Grumman Cunning preserves an actual +ordinary stacked-addition prerequisite; an unrevealed removal returns to +rotated hidden supply. All remain user interpretations, not publisher rulings. + +BEFORE genuine Basic setups without/with Nexus reached turn2 Collection +after63/69 original actions, native population14 and a real first-Mentat +Robbery placement in Arrakeen. Removal rejected the custody-zone guard without +mutation. Native Grumman Cunning to Red Chasm likewise rejected the ordinary +stronghold-only destination while holding the actual Moritani card. + +AFTER removal uses original `returnTerror` and hidden ID rotation, pays4 once, +preserves the six faces/other placements and leaves `placementTurn` independent. +The old public ID/source location stays historical, not live supply identity. +High-Grumman Cunning reuses physical supply validation and adds a source- +discriminated phase7 receipt with original Grumman event and ordinary +prerequisite. Its atomic completion consumes the actual Nexus and pays4; +Mentat receipts/events and original cancellation remain unchanged. +The source bridge validates the real completed current Collection frame and +its historical location authority after later turns. No extra Karama window. + +Native Basic/Advanced addition, failed selection/replay, prerequisite loss, +four existing legal policies, a later real one-force Guild Polar shipment/ +Terror entry, and subsequent unrevealed Cunning-token return are exercised. +The original other board token remains, and only the selected live Cunning +authorization retires on return. Native removal permits later genuine Mentat +redeployment of the same physical face under a new hidden supply ID. +Initial new behavior tests14/14 pass. A child receipt-mutation test used an +unnarrowed type-query union; explicit Grumman discriminant types repair it +without production change. One old persisted removal-veto assertion is removed; +its independent SQL decline behavior remains. + +Actual390px `GameTable` returns Robbery from Arrakeen, pays12→16, rotates +all six available IDs and leaves Mentat usage1. Separate actual Cunning +checkbox/destination controls place an available Sneak Attack at Red Chasm, +spend the Moritani card, pay12→16 and close the original choice with a phase7 +Grumman source/prerequisite receipt. Browser errors empty, no root overflow. +`/tmp/dune-ruled-grumman-ui-proof.json` records local source/action results. +Owned tab/host/scaffold removed. No saved room, production server or mode +gate changed; comprehensive assurance remains deferred. + +Final frozen14-file union92/92 and `npm run check:quick` pass. App build +passes with existing module-registration, large-chunk and unknown-route +classification warnings. The first union used a nonexistent `terror-location` +filename fragment and ran no tests; its corrected selection first had62/63 +pass with the obsolete persisted removal-veto failure, then passes after that +guard assertion is deleted. Independent persisted decline coverage remains. +No complete-mode, broad-suite or deployed acceptance claim follows. + ## 13 October 2026 — documented Skills CLI admission repaired Actual `tools/start-prototype.ts --profile guild-betrayal --leader-skills` diff --git a/docs/NEXUS_CARD_RULES.md b/docs/NEXUS_CARD_RULES.md index 68777ecf..562dda99 100644 --- a/docs/NEXUS_CARD_RULES.md +++ b/docs/NEXUS_CARD_RULES.md @@ -2,6 +2,20 @@ 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. +## Moritani Cunning and Grumman user cutover — 14 October 2026 + +The user selected supply-only Cunning, continued HMS exclusion, an ordinary +eligible Grumman addition prerequisite, and unrevealed Grumman removal to +rotated hidden supply. The connected Collection controls now apply that +separate source without consuming the native Mentat opportunity. +The phase7 receipt binds the actual Grumman event and original ordinary +prerequisite; no eligible stacked-addition source means no Cunning spend. +Physical Nexus disposal, one four-spice award, later static-territory Terror +entry and hidden return use their original contracts. +[Selected interpretations and runtime](NEXUS_MORITANI_RULES.md#user-rulings--14-october-2026); +[removal custody](GRUMMAN_STACKED_TERROR_RULES.md#user-ruling--14-october-2026). +No publisher ruling, new cancellation window or complete-mode gate is claimed. + ## Classic Nexus with normal Banker income — 13 October 2026 Fresh `banker-income --nexus-cards` now composes the existing deferred normal @@ -572,10 +586,11 @@ Advanced Harkonnen remains excluded. Moritani preserves original Advanced skill-first assassination, normal-Traitor forfeiture for the rest of the game, original post-loss printed bounty and one actual private Mentat replacement. Basic does not gain that Advanced power. -Cunning remains an earned Mentat supply placement into a static Arrakis -territory/stack, not enhanced relocation/HMS/Grumman; the earlier material -question remains unanswered. Native cancellation and later ordinary relocation -retain their original timing and physical custody. +Cunning remains supply-only and excludes HMS/Homeworld destinations under the +14 October user choices. Native Mentat placement retains its original timing +and cancellation; the separate high-Grumman source now admits Cunning only +after an ordinary stacked addition is eligible. It creates no opportunity and +does not consume Mentat placement. Ordinary relocation remains unchanged. Ordinary skill support/rescue, bank-held subsidy/defenses/income, card cleanup and mandatory original-winner Tech reuse existing handlers. Mandatory Advanced diff --git a/docs/NEXUS_MORITANI_RULES.md b/docs/NEXUS_MORITANI_RULES.md index 72be8930..fdb14efd 100644 --- a/docs/NEXUS_MORITANI_RULES.md +++ b/docs/NEXUS_MORITANI_RULES.md @@ -10,6 +10,51 @@ The [GF9 Ecaz & Moritani rulebook](https://www.gf9games.com/dune/wp-content/uplo The [Grumman component photograph](https://boardgamegeek.com/image/7767034/dune-ecaz-and-moritani) supplies its separate high-population Collection addition/removal and four-spice award. Its wording and unresolved removal custody are recorded in the linked Grumman audit. Bounded fresh publisher/designer searches found no additional authenticated clarification of Cunning relocation, Hidden Mobile Stronghold placement, or Cunning combined with Grumman. Search results from community discussions were not adopted as rulings. +## User rulings — 14 October 2026 + +The user selected **supply placement only**, not enhanced relocation, and +**keep the Hidden Mobile Stronghold excluded**. Ordinary relocation retains +its native destinations; Homeworlds remain excluded from Terror placement. + +For high-Grumman Collection addition, **preserve the prerequisite**: there +must already be an eligible ordinary supply addition to a stronghold holding +Terror. Cunning may then broaden the actual selected destination to static +Arrakis. It does not create an underlying opportunity when none exists. +The selected physical supply token, current high population, one Collection +operation/four-spice award and separate Mentat allowance remain required. +The native Grumman operation is immediate; no new Karama window is introduced. + +These are recorded user interpretations, not publisher clarifications. +Unrevealed Grumman removal independently +[returns to hidden supply](GRUMMAN_STACKED_TERROR_RULES.md#user-ruling--14-october-2026). +Current runtime evidence remains in the dashboard; no complete-mode claim +follows from resolving these source questions. + +## Runtime follow-through — 14 October 2026 + +The Collection controls use the actual high-Grumman frame and current ordinary +addition quote before offering Cunning. An accepted static-destination supply +placement spends the singleton Nexus, pays four spice and completes that one +Grumman frame atomically. It does not open a new Karama window or consume the +separate native Mentat allowance. + +The source-discriminated receipt records `grumman`, phase7, the original +Collection event and an eligible ordinary prerequisite. Original Mentat +receipts/events remain unchanged. Existing live/historical location authority +permits a later real entry at the Cunning territory and retires when that +selected token returns, while retaining its historical receipt. + +Genuine native fixtures provide prior ordinary placement and an actual worm/ +Nexus draw without assigned runtime forces, wallets or hands. New evidence: +[native Cunning source/entry/return](../tests/grumman-cunning-runtime.test.ts), +[pure receipt/placement boundaries](../tests/nexus-moritani-grumman.test.ts), +[hidden return and later Mentat reuse](../tests/grumman-supply-return.test.ts). +Actual390px Collection controls separately prove return and Red Chasm +Cunning addition for12→16, with native Mentat usage1 unchanged. +Local proof is `/tmp/dune-ruled-grumman-ui-proof.json`; no saved room or +production acceptance is claimed. + + ## Resolved effect and ordinary composition 1. **One earned placement.** A native unallied Moritani holder can spend this card when making an otherwise earned placement. Ordinary Mentat supply placement is a concrete supported source in Basic and Advanced. The selected physical token must actually be available; no removed or front-of-shield Extortion token becomes supply. Spending the card neither creates a seventh token nor creates another Mentat action. @@ -21,7 +66,7 @@ The [Grumman component photograph](https://boardgamegeek.com/image/7767034/dune- Ordinary later relocation can move a Cunning-placed token out to a native-legal destination. Its unusual original location does not change its placed custody status or grant an enlarged destination for the later move. Returning a token to hidden supply ends that placement's custody interval; redeployment requires its own authorization and the existing supply-ID rotation. -## Material boundaries +## Source ambiguities resolved by the selected interpretations **Relocation is not expressly placement.** The native rule deliberately offers placing a token or moving one, whereas Cunning names placement. Treating the card as a blanket modifier on the current combined place/move UI would silently include a second source category. No inspected primary clarification establishes that extension. Preserve ordinary relocation; do not present enhanced relocation as a settled printed right. @@ -29,15 +74,18 @@ Ordinary later relocation can move a Cunning-placed token out to a native-legal **Grumman is a separate earned placement source.** Cunning has no Mentat-only wording, so a reusable source contract should represent Collection addition as well. A high-Grumman addition from supply is naturally a placement. The unresolved interaction is its destination condition: does Cunning replace Grumman's requirement for a stronghold already containing Terror, allowing an empty territory, or does that requirement establish an eligible addition before Cunning modifies the destination? The former follows a broad destination override; the latter follows an underlying-opportunity prerequisite. No inspected source explicitly chooses between them. Do not silently gate all Grumman use by inventing a Mentat restriction, or describe either destination composition as a publisher ruling. An enhanced Collection integration also needs to preserve its own once-only outcome and four-spice transaction, separate from Mentat usage and its cancellation source. -The coordinator has now asked one combined question about enhanced relocation, the mobile stronghold and Grumman; its answer is pending. The already pending question about unrevealed Grumman removal custody remains unchanged. Cunning places a token; it does not answer removal custody. Other incomplete Terror effects and combined reaction boundaries remain independent release limitations. +The combined interpretation question is resolved by the user rulings above. +The separate unrevealed Grumman removal question is also resolved: return to +hidden supply without revelation. These decisions do not establish a publisher +answer to the original ambiguities or certify other Terror combinations. -## Smallest complete integration for the resolved source +## Original source-audit integration plan (historical) Implement a full ordinary Mentat supply-placement path over static Arrakis territories, with saved cancellation and all supported later entry effects. Keep the unresolved source/destination combinations explicit; a destination-only UI patch is insufficient. Use a separate Cunning placement quote and receipt instead of widening ordinary [placeTerror](../game/moritani-terror.ts). Suggested original evidence: version, event, owner, turn, phase, source kind and source event, physical token, original custody, destination, roster, and original Terror-state commitment. Keep pending/completed/canceled stage evidence outside mutable current token custody. A completed placement authorizes the corresponding non-stronghold custody interval; a historical receipt alone must not authorize arbitrary later relocation, a returned token's new ID, or another token. -Current adapters requiring coordinated review: +Adapters identified at the original source-audit checkpoint (not current absence claims): - [grumman-collection.ts](../game/grumman-collection.ts) validates every placed token against the five ordinary strongholds even when merely quoting unrelated Collection choices. That registry validation must accept independently authorized Cunning custody without widening Grumman's ordinary addition quote. - [terror-entry-receipt.ts](../game/terror-entry-receipt.ts) and [moritani-alliance-cancellation.ts](../game/moritani-alliance-cancellation.ts) restrict entry territories to those same five locations. Broaden intrinsic territory validity and separately prove the live token's authorized location. Preserve candidate and selected-token signatures through all children. diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index efe169a9..4402798d 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,6 +1,27 @@ # Rule decision index -Updated 13 October 2026. This is the authoritative navigation index for existing decisions, not a replacement rulebook or a new ruling. Detailed linked contracts retain their source citations and exact supported boundaries. Runtime follow-ups supersede historical absence claims; passing tests establish software behavior, not publisher authority. +Updated 14 October 2026. This is the authoritative navigation index for existing decisions, not a replacement rulebook or a new ruling. Detailed linked contracts retain their source citations and exact supported boundaries. Runtime follow-ups supersede historical absence claims; passing tests establish software behavior, not publisher authority. + +## User decisions — 14 October 2026 + +- **Moritani Cunning / relocation:** supply placement only. Ordinary relocation + remains available, but Cunning does not broaden its destinations. +- **Moritani Cunning / HMS:** keep the Hidden Mobile Stronghold itself excluded. + Printed static Arrakis territories remain within the destination grant; + Homeworlds remain excluded. +- **Moritani Cunning / Grumman addition:** preserve the ordinary prerequisite. + An actual high-Grumman supply addition must first be available at an eligible + ordinary stronghold already containing Terror; Cunning may then broaden the + selected destination. It creates no underlying placement opportunity. + [Selected Cunning contract](NEXUS_MORITANI_RULES.md#user-rulings--14-october-2026). +- **Unrevealed Grumman removal:** return the selected placed token to hidden + supply without revealing its face, using original hidden-supply ID rotation. + Keep one operation and one four-spice award per Collection, separate from + native Mentat placement usage. + [Removal ruling](GRUMMAN_STACKED_TERROR_RULES.md#user-ruling--14-october-2026). + +These are user-selected interpretations, not newly found publisher rulings. +No public module/faction or complete-mode gate is opened. ## User decisions — 13 October 2026 diff --git a/game/engine.ts b/game/engine.ts index 673eb628..b8823ff1 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -64,7 +64,7 @@ import { replaceNexusFaceDancers, validateNexusFaceDancerHistory, type NexusFace import { createNexusSuboids, validateNexusSuboids, nexusSuboidsActive, type NexusSuboidReceipt } from './nexus-suboids'; import { quoteNexusAdvisors, createNexusAdvisors, validateNexusAdvisors, type NexusAdvisorReceipt } from './nexus-advisors'; import { createNexusSardaukar, validateNexusSardaukar, type NexusSardaukarReceipt } from './nexus-sardaukar'; -import { createNexusMoritani, validateNexusMoritani, quoteNexusMoritaniPlacement, type NexusMoritaniReceipt } from './nexus-moritani'; +import { createNexusMoritani, validateNexusMoritani, quoteNexusMoritaniPlacement, quoteNexusMoritaniGrummanPlacement, type NexusMoritaniReceipt } from './nexus-moritani'; import { moritaniBetrayalOffer } from './nexus-moritani-betrayal'; import { ecazBetrayalOffer, quoteEcazBetrayal, validateEcazBetrayalSnapshot, type EcazBetrayalSnapshot } from './nexus-ecaz-betrayal'; import { fremenBetrayalOffer, fremenBetrayalSignature, validateFremenBetrayal, type FremenNexusBetrayal, type StoredFremenNexusBetrayal } from './nexus-fremen-betrayal'; @@ -19383,30 +19383,78 @@ function resumeGrummanCollection(g: Game) { frame.signature = grummanCollectionSignature(frame); g.decision = { kind: 'grummanCollection', player: frame.player, event: frame.event }; } +function grummanCunningGrant(g: Game, frame: GrummanCollection, + quote: ReturnType) { + const owner = getPlayer(g, frame.player); + if (g.nexusCards?.cards?.hands[owner.id] !== 'moritani') return null; + return { event: JSON.stringify(['nexusMoritaniGrumman', g.turn, owner.id, frame.event]), + blocked: owner.ally ? 'Nexus Cunning requires an unallied Moritani player.' : quote.blocked, + prerequisite: quote.destinations[0] ?? null }; +} function decideGrummanCollection(g: Game, p: Player, action: Action) { const frame = g.grummanCollection; requireRule(frame?.stage === 'choice' && frame.player === p.id && frame.event === action.event, 'Choose the current Grumman Collection opportunity.'); if (action.decline === true) { - requireRule(action.token === undefined && action.territory === undefined && action.mode === undefined, - 'Declining Grumman does not move a token or collect spice.'); + requireRule(action.token === undefined && action.territory === undefined && + action.mode === undefined && action.nexus === undefined, + 'Declining Grumman does not move a token, spend Cunning or collect spice.'); frame.outcome = 'decline'; log(g, `${p.name} declined Grumman’s optional Terror change; no bank spice was collected.`); } else { requireRule(action.mode === 'add' || action.mode === 'remove', 'Choose a Grumman token operation.'); requireRule(typeof action.token === 'string', 'Choose a physical Terror token.'); - const destination = typeof action.territory === 'string' ? action.territory : ''; - requireRule(action.mode !== 'add' || !!destination, 'Choose a stronghold for the added token.'); - const intent = action.mode === 'remove' - ? { mode: 'remove' as const, token: action.token } - : { mode: 'add' as const, token: action.token, destination }; - const quote = homeworldRule(() => quoteGrummanCollectionAction(g, p.id, intent)); - requireRule(Number.isSafeInteger(p.spice + quote.amount), 'Grumman income would overflow the spice balance.'); - g.moritaniTerror = quote.state; - p.spice += quote.amount; - frame.outcome = 'add'; frame.token = action.token; frame.territory = destination; - log(g, `${p.name} added one hidden Terror token to ${territory(frame.territory).name}, which already held Terror, and received 4 spice from the bank. The separate Mentat placement remains available according to its own usage.`, - { faction: p.faction, name: 'Grumman Collection' }); + requireRule(Number.isSafeInteger(p.spice + 4), 'Grumman income would overflow the spice balance.'); + if (action.mode === 'add' && action.nexus !== undefined) { + const ordinary = homeworldRule(() => quoteGrummanCollection(g, p.id)); + const grant = grummanCunningGrant(g, frame, ordinary); + requireRule(grant && !grant.blocked && action.nexus === grant.event && grant.prerequisite, + grant?.blocked ?? 'Choose the current Cunning modifier for an already eligible Grumman addition.'); + requireRule(typeof action.territory === 'string', 'Choose a permitted Arrakis destination.'); + const placed = nexusRule(() => quoteNexusMoritaniGrummanPlacement(g.moritaniTerror!, + action.token as string, action.territory as string, grant.prerequisite!)); + const receipt = nexusRule(() => createNexusMoritani(g, p.id, action.token as string, + action.territory as string, { source: 'grumman', sourceEvent: frame.event, + prerequisite: grant.prerequisite! })); + const record = { receipt, stage: 'complete' as const, before: JSON.stringify(g.moritaniTerror), + frame: JSON.stringify({ token: receipt.token, territory: receipt.territory, turn: receipt.turn, + nexusEvent: receipt.event, sourceEvent: frame.event, prerequisite: grant.prerequisite }), + signature: '' }; + record.signature = nexusMoritaniRecordSignature(record); + g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); + (g.nexusMoritaniHistory ??= []).push(record); + g.nexusMoritaniLast = { event: receipt.event, stage: record.stage }; + g.moritaniTerror = placed; + (g.nexusMoritaniLocations ??= {})[receipt.token] = { event: receipt.event, territory: receipt.territory }; + frame.outcome = 'add'; frame.token = receipt.token; frame.territory = receipt.territory; + frame.nexusEvent = receipt.event; + p.spice += 4; + log(g, `${p.name} used Moritani Nexus Cunning to add one hidden Terror token to ${territory(receipt.territory).name} from an eligible Grumman addition and received 4 spice from the bank. The separate Mentat placement is unchanged.`, + { faction: p.faction, name: 'Grumman Collection' }); + } else { + requireRule(action.nexus === undefined, 'Returning a Terror token does not use Cunning.'); + requireRule(action.mode !== 'remove' || action.territory === undefined, + 'Returning a Terror token has no destination selection.'); + const destination = typeof action.territory === 'string' ? action.territory : ''; + requireRule(action.mode !== 'add' || !!destination, 'Choose a stronghold for the added token.'); + const original = g.moritaniTerror!.tokens.find(token => token.id === action.token); + const intent = action.mode === 'remove' + ? { mode: 'remove' as const, token: action.token } + : { mode: 'add' as const, token: action.token, destination }; + const quote = homeworldRule(() => quoteGrummanCollectionAction(g, p.id, intent, random)); + g.moritaniTerror = quote.state; + p.spice += quote.amount; + frame.outcome = action.mode; frame.token = action.token; + frame.territory = action.mode === 'remove' ? original!.location! : destination; + if (action.mode === 'remove') { + if (g.nexusMoritaniLocations) delete g.nexusMoritaniLocations[action.token]; + log(g, `${p.name} returned one unrevealed Terror token from ${territory(frame.territory).name} to hidden supply and received 4 spice from the bank. The separate Mentat placement is unchanged.`, + { faction: p.faction, name: 'Grumman Collection' }); + } else { + log(g, `${p.name} added one hidden Terror token to ${territory(frame.territory).name}, which already held Terror, and received 4 spice from the bank. The separate Mentat placement remains available according to its own usage.`, + { faction: p.faction, name: 'Grumman Collection' }); + } + } } frame.stage = 'complete'; frame.signature = grummanCollectionSignature(frame); @@ -19415,9 +19463,14 @@ function projectedGrummanCollection(g: Game, player: string) { const frame = g.grummanCollection; if (frame?.stage !== 'choice') return null; const quote = homeworldRule(() => quoteGrummanCollection(g, frame.player)); + const own = player === frame.player; + const cunning = own ? grummanCunningGrant(g, frame, quote) : null; return { event: frame.event, player: frame.player, blocked: quote.blocked, removeBlocked: quote.removeBlocked, - tokens: player === frame.player ? quote.tokens.map((token) => ({ id: token.id, kind: token.kind })) : [], - destinations: player === frame.player ? quote.destinations.map((id) => ({ id, name: territory(id).name })) : [] }; + tokens: own ? quote.tokens.map(token => ({ id: token.id, kind: token.kind })) : [], + destinations: own ? quote.destinations.map(id => ({ id, name: territory(id).name })) : [], + removable: own ? quote.removable.map(token => ({ id: token.id, kind: token.kind, + territory: token.location!, name: territory(token.location!).name })) : [], + cunning: cunning ? { ...cunning, destinations: TERRITORIES.map(place => ({ id: place.id, name: place.name })) } : null }; } function commitCollection( g: Game, @@ -26766,7 +26819,7 @@ function nexusMoritaniOffer(g: Game, owner: string, decision: Game['decision'] = else if (g.response || g.truthtrance || g.phaseOpening || g.pendingKarama || g.pendingNullentropy || g.pendingTreacheryDiscard) blocked = 'Finish the current interaction before declaring this placement.'; else if (g.moritaniTerror?.placementTurn === g.turn) blocked = 'This turn’s placement opportunity is already used.'; - else if (!tokens.length) blocked = 'No supply token is available. Cunning relocation awaits a ruling.'; + else if (!tokens.length) blocked = 'No supply token is available. Cunning does not enhance ordinary relocation.'; return {event:JSON.stringify(['nexusMoritani',g.turn,p.id]),blocked,tokens, destinations:TERRITORIES.map(t => t.id)}; } @@ -26789,9 +26842,23 @@ function nexusMoritaniIntegrity(g: Game) { 'Moritani Cunning has a changed or duplicated placement record.'); nexusRule(() => validateNexusMoritani(g,record.receipt)); const before = nexusRule(() => JSON.parse(record.before)) as TerrorState; - nexusRule(() => quoteNexusMoritaniPlacement(before,record.receipt.token,record.receipt.territory,record.receipt.turn)); - requireRule(record.frame === JSON.stringify({token:record.receipt.token,territory:record.receipt.territory,turn:record.receipt.turn,nexusEvent:record.receipt.event}), - 'Moritani Cunning has a changed original placement frame.'); + if (record.receipt.source === 'grumman') { + const receipt = record.receipt; + nexusRule(() => quoteNexusMoritaniGrummanPlacement(before, receipt.token, receipt.territory, receipt.prerequisite)); + requireRule(record.stage === 'complete' && record.frame === JSON.stringify({ + token: receipt.token, territory: receipt.territory, turn: receipt.turn, nexusEvent: receipt.event, + sourceEvent: receipt.sourceEvent, prerequisite: receipt.prerequisite }), + 'Moritani Cunning has changed its original Grumman addition source.'); + if (receipt.turn === g.turn) requireRule(g.grummanCollection?.event === receipt.sourceEvent && + g.grummanCollection.stage === 'complete' && g.grummanCollection.outcome === 'add' && + g.grummanCollection.token === receipt.token && g.grummanCollection.territory === receipt.territory && + g.grummanCollection.nexusEvent === receipt.event, + 'Moritani Cunning has lost its completed current Grumman opportunity.'); + } else { + nexusRule(() => quoteNexusMoritaniPlacement(before,record.receipt.token,record.receipt.territory,record.receipt.turn)); + requireRule(record.frame === JSON.stringify({token:record.receipt.token,territory:record.receipt.territory,turn:record.receipt.turn,nexusEvent:record.receipt.event}), + 'Moritani Cunning has a changed original placement frame.'); + } const original = before.tokens.find(t => t.id === record.receipt.token)!; const current = g.moritaniTerror?.tokens.find(t => t.id === record.receipt.token); requireRule(!current || current.kind === original.kind, 'Moritani Cunning has changed the original physical token face.'); diff --git a/game/grumman-collection-options.ts b/game/grumman-collection-options.ts index 3eb51892..691a4e0b 100644 --- a/game/grumman-collection-options.ts +++ b/game/grumman-collection-options.ts @@ -19,39 +19,47 @@ export function grummanCollectionChoice( g: GameView, token: string, territory: string, + mode: 'add' | 'remove' = 'add', + useNexus = false, ): { blocked: string | null; action: Action | null } { const offer = g.grummanCollection; let blocked: string | null = null; if (!grummanCollectionCanAct(g)) blocked = 'Wait for the current choice to finish.'; - else if (offer!.blocked) blocked = offer!.blocked; - else if (!offer!.tokens.some((candidate) => candidate.id === token)) - blocked = 'Choose a currently available Terror token.'; - else if (!offer!.destinations.some((candidate) => candidate.id === territory)) - blocked = 'Choose a current destination stronghold.'; + else if (mode === 'remove') { + if (useNexus || territory) blocked = 'Returning a token does not use Cunning or select a destination.'; + else if (offer!.removeBlocked) blocked = offer!.removeBlocked; + else if (!offer!.removable.some(candidate => candidate.id === token)) + blocked = 'Choose one of your currently placed Terror tokens.'; + } else { + if (useNexus && !offer!.cunning) blocked = 'You do not have a current Moritani Cunning addition.'; + else if (useNexus ? offer!.cunning!.blocked : offer!.blocked) + blocked = useNexus ? offer!.cunning!.blocked : offer!.blocked; + else if (!offer!.tokens.some(candidate => candidate.id === token)) + blocked = 'Choose a currently available Terror token.'; + else if (!(useNexus ? offer!.cunning!.destinations : offer!.destinations).some(candidate => candidate.id === territory)) + blocked = 'Choose a current permitted addition destination.'; + } return { blocked, - action: blocked - ? null - : { - type: 'decision', - event: offer!.event, - mode: 'add', - token, - territory, - }, + action: blocked ? null : mode === 'remove' + ? { type: 'decision', event: offer!.event, mode, token } + : { type: 'decision', event: offer!.event, mode, token, territory, + ...(useNexus ? { nexus: offer!.cunning!.event } : {}) }, }; } export function grummanCollectionActions(g: GameView): Action[] { if (!grummanCollectionCanAct(g)) return []; const offer = g.grummanCollection!; - const choice = grummanCollectionChoice( - g, - offer.tokens[0]?.id ?? '', - offer.destinations[0]?.id ?? '', - ); - return [ - choice.action ?? { type: 'decision', event: offer.event, decline: true }, - ]; + if (offer.cunning && !offer.cunning.blocked) { + const choice = grummanCollectionChoice(g, offer.tokens[0]?.id ?? '', + offer.cunning.destinations[0]?.id ?? '', 'add', true); + if (choice.action) return [choice.action]; + } + const addition = grummanCollectionChoice(g, offer.tokens[0]?.id ?? '', + offer.destinations[0]?.id ?? ''); + if (addition.action) return [addition.action]; + const removal = grummanCollectionChoice(g, offer.removable[0]?.id ?? '', '', 'remove'); + return [removal.action ?? { type: 'decision', event: offer.event, decline: true }]; } diff --git a/game/grumman-collection-return.ts b/game/grumman-collection-return.ts index de030f71..0464f7bb 100644 --- a/game/grumman-collection-return.ts +++ b/game/grumman-collection-return.ts @@ -1,36 +1,48 @@ import type { Game } from './engine'; import { HomeworldCustodyError } from './homeworld-custody'; import { TERROR_STRONGHOLDS } from './moritani-terror'; +import { terrorLocationAllowed, type TerrorLocationContext } from './terror-location'; export type GrummanCollection = { event: string; turn: number; player: string; stage: 'waiting' | 'choice' | 'complete'; - outcome?: 'add' | 'decline' | 'expired'; + outcome?: 'add' | 'remove' | 'decline' | 'expired'; token?: string; territory?: string; + /** Earned Grumman Cunning receipt, never a removal or Mentat source. */ + nexusEvent?: string; signature: string; }; /** Bind phase progress so a completed operation cannot rewind to a choice. */ export function grummanCollectionSignature(frame: Omit): string { return JSON.stringify(['grummanCollection', frame.event, frame.turn, frame.player, - frame.stage, frame.outcome ?? null, frame.token ?? null, frame.territory ?? null]); + frame.stage, frame.outcome ?? null, frame.token ?? null, frame.territory ?? null, + ...(frame.nexusEvent === undefined ? [] : [frame.nexusEvent])]); } -export function validateGrummanCollection(g: Pick): void { +export function validateGrummanCollection(g: Pick & TerrorLocationContext): void { const frame = g.grummanCollection; if (frame === undefined) return; const text = (s: unknown): s is string => typeof s === 'string' && !!s; if (!frame || typeof frame !== 'object' || Array.isArray(frame) || - Object.keys(frame).some((key) => !['event', 'turn', 'player', 'stage', 'outcome', 'token', 'territory', 'signature'].includes(key)) || + Object.keys(frame).some((key) => !['event', 'turn', 'player', 'stage', 'outcome', 'token', 'territory', 'nexusEvent', 'signature'].includes(key)) || !text(frame.event) || !text(frame.player) || !Number.isSafeInteger(frame.turn) || frame.turn < 1 || frame.turn > g.turn || !g.homeworlds?.custody || g.players.filter((p) => p.id === frame.player && p.faction === 'moritani').length !== 1 || !['waiting', 'choice', 'complete'].includes(frame.stage) || (frame.stage !== 'complete' && (frame.turn !== g.turn || g.phase !== 7)) || - (frame.stage === 'complete' ? !['add', 'decline', 'expired'].includes(frame.outcome ?? '') : frame.outcome !== undefined) || - (frame.outcome === 'add' ? !text(frame.token) || !TERROR_STRONGHOLDS.includes(frame.territory ?? '') : frame.token !== undefined || frame.territory !== undefined) || + (frame.stage === 'complete' ? !['add', 'remove', 'decline', 'expired'].includes(frame.outcome ?? '') : frame.outcome !== undefined) || + (frame.outcome === 'add' || frame.outcome === 'remove' + ? !text(frame.token) || !text(frame.territory) || + (frame.outcome === 'add' && frame.nexusEvent !== undefined + ? !text(frame.nexusEvent) || !terrorLocationAllowed(g, frame.token, frame.territory, frame.nexusEvent, false, true) + : frame.outcome === 'add' + ? !TERROR_STRONGHOLDS.includes(frame.territory) + : !terrorLocationAllowed(g, frame.token, frame.territory, undefined, false, true)) + : frame.token !== undefined || frame.territory !== undefined) || + (frame.nexusEvent !== undefined && frame.outcome !== 'add') || frame.signature !== grummanCollectionSignature(frame)) throw new HomeworldCustodyError('The saved Grumman Collection opportunity no longer matches its original phase and completion.'); } diff --git a/game/grumman-collection.ts b/game/grumman-collection.ts index 2087f385..967d8f06 100644 --- a/game/grumman-collection.ts +++ b/game/grumman-collection.ts @@ -4,6 +4,7 @@ import { HomeworldCustodyError } from './homeworld-custody'; import { homeworldContext } from './homeworld-game'; import { homeworldPopulations } from './homeworld-population'; import { + returnTerror, TERROR_KINDS, TERROR_STRONGHOLDS, type TerrorState, @@ -22,16 +23,15 @@ export type GrummanCollectionQuote = { /** Owner-only physical identities and secret faces; never a public projection. */ tokens: TerrorToken[]; destinations: string[]; - removeBlocked: string; + /** Owner-only placed identities, including historically authorized Cunning locations. */ + removable: TerrorToken[]; + removeBlocked: string | null; }; export type GrummanCollectionAction = | { mode: 'add'; token: string; destination: string } | { mode: 'decline' } | { mode: 'remove'; token: string }; -const removalBlock = - 'Grumman Terror removal awaits a ruling on the removed token’s next custody zone.'; - function fail(message: string): never { throw new HomeworldCustodyError(message); } @@ -118,6 +118,9 @@ export function quoteGrummanCollection( ), ) : []; + const removable = high + ? state.tokens.filter((token) => token.status === 'placed') + : []; return { player: ownerId, population: home?.population ?? null, @@ -131,16 +134,23 @@ export function quoteGrummanCollection( : null, tokens: tokens.map((token) => ({ ...token })), destinations: [...destinations], - removeBlocked: removalBlock, + removable: removable.map((token) => ({ ...token })), + removeBlocked: !high + ? 'Grumman needs at least eight native forces for its collection opportunity.' + : !removable.length + ? 'There is no placed Terror token to return to supply.' + : null, }; } -/** A successful addition and its four bank spice are one quote. No effects, - * randomness, supply rotation or Mentat placement allowance are consumed here. */ +/** A successful operation and its four bank spice are one quote. Removal + * rotates hidden supply with private randomness; Mentat usage remains separate. */ export function quoteGrummanCollectionAction( context: GrummanCollectionContext, ownerId: string, action: GrummanCollectionAction, + random: () => number = () => + crypto.getRandomValues(new Uint32Array(1))[0] / 4294967296, ): { state: TerrorState; amount: 0 | 4 } { const state = stateFor(context, ownerId); if ( @@ -156,7 +166,13 @@ export function quoteGrummanCollectionAction( fail('Choose one Grumman collection operation.'); if (action.mode === 'decline') return { state: structuredClone(state), amount: 0 }; - if (action.mode === 'remove') fail(removalBlock); + if (action.mode === 'remove') { + const quote = quoteGrummanCollection(context, ownerId); + if (quote.removeBlocked) fail(quote.removeBlocked); + if (!quote.removable.some((token) => token.id === action.token)) + fail('Choose a currently placed physical Terror token to return to supply.'); + return { state: returnTerror(state, action.token, random), amount: 4 }; + } if (action.mode !== 'add') fail('Choose one Grumman collection operation.'); const quote = quoteGrummanCollection(context, ownerId); if (quote.blocked) fail(quote.blocked); diff --git a/game/nexus-moritani.ts b/game/nexus-moritani.ts index 83f084a9..1b7761c6 100644 --- a/game/nexus-moritani.ts +++ b/game/nexus-moritani.ts @@ -1,16 +1,18 @@ import { TERRITORIES } from './board'; import type { FactionId } from './catalog'; -import { TERROR_KINDS, type TerrorState } from './moritani-terror'; +import { + TERROR_KINDS, + TERROR_STRONGHOLDS, + type TerrorState, +} from './moritani-terror'; export type NexusMoritaniContext = { turn: number; players: readonly { id: string; faction: FactionId }[]; }; -export type NexusMoritaniReceipt = { +type NexusMoritaniEvidence = { version: 1; event: string; - source: 'mentat'; - phase: 8; turn: number; owner: string; token: string; @@ -18,6 +20,16 @@ export type NexusMoritaniReceipt = { roster: { id: string; faction: FactionId }[]; signature: string; }; +export type NexusMoritaniGrummanSource = { + source: 'grumman'; + sourceEvent: string; + prerequisite: string; +}; +export type NexusMoritaniReceipt = NexusMoritaniEvidence & + ( + | { source: 'mentat'; phase: 8 } + | (NexusMoritaniGrummanSource & { phase: 7 }) + ); function requireMoritani(value: unknown, message: string): asserts value { if (!value) throw new Error(message); } @@ -62,7 +74,7 @@ function roster(context: NexusMoritaniContext) { .sort((a, b) => a.id.localeCompare(b.id)); } function signature(receipt: NexusMoritaniReceipt): string { - return JSON.stringify([ + const evidence = [ receipt.version, receipt.event, receipt.source, @@ -72,7 +84,10 @@ function signature(receipt: NexusMoritaniReceipt): string { receipt.token, receipt.territory, receipt.roster.map((p) => [p.id, p.faction]), - ]); + ]; + if (receipt.source === 'grumman') + evidence.push(receipt.sourceEvent, receipt.prerequisite); + return JSON.stringify(evidence); } /** Historical evidence does not infer authorization from current token custody. * The engine owns live placement eligibility, cancellation and progress binding. */ @@ -93,24 +108,44 @@ export function validateNexusMoritani( 'territory', 'roster', 'signature', + ...(receipt?.source === 'grumman' + ? ['sourceEvent', 'prerequisite'] + : []), ]) && - Object.keys(receipt).length === 10 && + Object.keys(receipt).length === + (receipt.source === 'grumman' ? 12 : 10) && receipt.version === 1 && - receipt.source === 'mentat' && - receipt.phase === 8 && + (receipt.source === 'mentat' + ? receipt.phase === 8 + : receipt.source === 'grumman' && + receipt.phase === 7 && + identifier(receipt.sourceEvent) && + TERROR_STRONGHOLDS.includes(receipt.prerequisite)) && whole(receipt.turn) && receipt.turn <= context.turn && identifier(receipt.token) && staticTerritory(receipt.territory) && seats.some((p) => p.id === receipt.owner && p.faction === 'moritani') && receipt.event === - JSON.stringify([ - 'nexusMoritani', - receipt.turn, - receipt.owner, - receipt.token, - receipt.territory, - ]) && + JSON.stringify( + receipt.source === 'grumman' + ? [ + 'nexusMoritaniGrumman', + receipt.turn, + receipt.owner, + receipt.token, + receipt.territory, + receipt.sourceEvent, + receipt.prerequisite, + ] + : [ + 'nexusMoritani', + receipt.turn, + receipt.owner, + receipt.token, + receipt.territory, + ], + ) && JSON.stringify(receipt.roster) === JSON.stringify(seats) && receipt.signature === signature(receipt), 'Moritani Nexus has lost its original placement, timing or owner.', @@ -121,18 +156,30 @@ export function createNexusMoritani( owner: string, token: string, territory: string, + source?: NexusMoritaniGrummanSource, ): NexusMoritaniReceipt { - const receipt: NexusMoritaniReceipt = { + requireMoritani( + source === undefined || + (knownKeys(source, ['source', 'sourceEvent', 'prerequisite']) && + Object.keys(source).length === 3 && + source.source === 'grumman'), + 'Moritani Nexus needs its original Grumman collection source.', + ); + const evidence: NexusMoritaniEvidence = { version: 1, - event: JSON.stringify([ - 'nexusMoritani', - context.turn, - owner, - token, - territory, - ]), - source: 'mentat', - phase: 8, + event: JSON.stringify( + source + ? [ + 'nexusMoritaniGrumman', + context.turn, + owner, + token, + territory, + source.sourceEvent, + source.prerequisite, + ] + : ['nexusMoritani', context.turn, owner, token, territory], + ), turn: context.turn, owner, token, @@ -140,27 +187,25 @@ export function createNexusMoritani( roster: roster(context), signature: '', }; + const receipt: NexusMoritaniReceipt = source + ? { ...evidence, ...source, phase: 7 } + : { ...evidence, source: 'mentat', phase: 8 }; receipt.signature = signature(receipt); validateNexusMoritani(context, receipt); return receipt; } -/** One earned Mentat supply placement. Static territory validity is intrinsic; - * the engine separately proves prior Cunning placements in unusual locations. */ -export function quoteNexusMoritaniPlacement( +function requireSupplyPlacement( state: TerrorState, tokenId: string, territory: string, - turn: number, -): TerrorState { +): void { requireMoritani( - whole(turn) && - staticTerritory(territory) && + staticTerritory(territory) && knownKeys(state, ['tokens', 'placementTurn', 'supplyEpoch']) && Array.isArray(state.tokens) && state.tokens.length === TERROR_KINDS.length && - (state.placementTurn === undefined || - (whole(state.placementTurn) && state.placementTurn < turn)) && + (state.placementTurn === undefined || whole(state.placementTurn)) && (state.supplyEpoch === undefined || (Number.isSafeInteger(state.supplyEpoch) && state.supplyEpoch >= 0)) && new Set(state.tokens.map((t) => t?.id)).size === TERROR_KINDS.length && @@ -176,12 +221,18 @@ export function quoteNexusMoritaniPlacement( : t.location === null) && (t.status !== 'extortion' || t.kind === 'extortion'), ), - 'Moritani Nexus needs six physical Terror tokens, a static Arrakis destination and an unused Mentat placement.', + 'Moritani Nexus needs six physical Terror tokens and a static Arrakis destination; Hidden Mobile Stronghold and Homeworld placement are excluded.', ); requireMoritani( state.tokens.some((t) => t.id === tokenId && t.status === 'available'), - 'Moritani Nexus must place a Terror token from available supply; enhanced relocation is unresolved.', + 'Moritani Nexus must place a Terror token from available supply; Cunning cannot enhance relocation.', ); +} +function placeSupplyToken( + state: TerrorState, + tokenId: string, + territory: string, +): TerrorState { return { ...state, tokens: state.tokens.map((t) => @@ -189,6 +240,43 @@ export function quoteNexusMoritaniPlacement( ? { ...t, status: 'placed', location: territory } : { ...t }, ), - placementTurn: turn, }; } + +/** One earned Mentat supply placement. Static territory validity is intrinsic; + * the engine separately proves prior Cunning placements in unusual locations. */ +export function quoteNexusMoritaniPlacement( + state: TerrorState, + tokenId: string, + territory: string, + turn: number, +): TerrorState { + requireSupplyPlacement(state, tokenId, territory); + requireMoritani( + whole(turn) && + (state.placementTurn === undefined || state.placementTurn < turn), + 'Moritani Nexus needs an unused Mentat placement for this turn.', + ); + return { ...placeSupplyToken(state, tokenId, territory), placementTurn: turn }; +} + +/** Broaden an already-available native Grumman addition, not its prerequisite. + * The engine owns population, Collection timing and source-frame authorization. + * This separate opportunity never consumes or inspects Mentat timing. */ +export function quoteNexusMoritaniGrummanPlacement( + state: TerrorState, + tokenId: string, + territory: string, + prerequisite: string, +): TerrorState { + requireSupplyPlacement(state, tokenId, territory); + requireMoritani( + TERROR_STRONGHOLDS.includes(prerequisite) && + state.tokens.some( + (token) => + token.status === 'placed' && token.location === prerequisite, + ), + 'Moritani Nexus needs an ordinary Grumman addition at a stronghold already containing Terror.', + ); + return placeSupplyToken(state, tokenId, territory); +} diff --git a/game/reference.ts b/game/reference.ts index e3729e75..4c6cc715 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3952,7 +3952,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'In supported payment cases, low Kaitain halves Emperor’s Treachery payment income and low Junction halves eligible Guild shipping income, rounded up. The payer still pays the full cost. For example, a five-spice auction payment gives low-population Emperor three spice and leaves two in the bank. The income opportunity shows the original payment, current receipt and bank remainder. A population change before collection updates the split; ordinary Karama can still prevent the separate faction income.', 'Guild’s own shipment contribution goes to the bank before any low-Junction reduction. Splits with two odd contributions from other factions remain unavailable while their rounding rule is unresolved; choose another funding split. Single-contributor payments and splits where both rounding interpretations agree remain supported. Occupier income is still unfinished.', 'With at least seven native forces on Giedi Prime, Harkonnen automatically gains two bank spice after collecting positive desert spice during Spice Collection, once for the whole phase. Several deposits still earn only two. Shared Ecaz spice qualifies when Harkonnen actually receives a positive allocation; a zero share, stronghold income or technology income does not qualify. Refreshing preserves the completed bonus.', - 'With fewer than eight native forces on Grumman, Moritani can reveal Terror only when at least three forces enter together. Count each incoming physical counter once, including advisors, and a concealed No-Field as one regardless of its hidden value. Forces already at the destination do not count. High Grumman’s separate Collection choice can add a token to an existing stronghold stack and collect four bank spice; removal still requires a custody ruling.', + 'With fewer than eight native forces on Grumman, Moritani can reveal Terror only when at least three forces enter together. Count each incoming physical counter once, including advisors, and a concealed No-Field as one regardless of its hidden value. Forces already at the destination do not count. High Grumman’s separate Collection choice can add a token to an existing stronghold stack or return an unrevealed placed token to hidden supply for four bank spice, once per Collection.', 'While Ecaz has at least seven native reserves, it automatically receives three bank spice for each discarded poison weapon, including its own. Poison Blade and Poison Tooth qualify; Chemistry qualifies only when used as a weapon. Poison defenses and Residual Poison do not qualify. Ecaz sees its income in a private Homeworld record; private discarded faces stay private.', 'Battle cleanup checks population when each card is actually discarded. Losing-card disposal comes before winner casualties. Mandatory winning cards, including a used Poison Tooth, are discarded after winner casualties and any Ixian substitution, before optional winning-card cleanup. If Ecaz falls below seven during its winning losses, its later card discards earn no poison income.', 'If Tleilax starts Revival at low population, it receives no bank income for other factions’ ordinary free revivals throughout that phase, even if its population rises. Paid revival receipts and the separately named Ghola reward remain available.', @@ -3962,7 +3962,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'At low population, Tupile lets CHOAM learn an opposing faction’s current spice and either its weapon count or defense count, once per faction for the entire game. CHOAM must have forces on that faction’s Homeworld, or that faction must have forces on Tupile. Either Emperor Homeworld supplies the same single entitlement. The answer is recorded privately as a historical observation and does not update when that faction’s hand or spice changes.', 'Choose weapons or defenses in the private Tupile panel at a settled action boundary. The server answers immediately without an opponent confirmation, and does not expose individual cards. Count physical held Treachery cards by their default category: Weirding Way is a weapon, Chemistry is a defense, while Worthless cards and leader substitutes count as neither. Selecting the other category later cannot renew a faction’s used entitlement.', 'Occupation removes Tupile’s low advantage. New Homeworld games record qualifying foreign presence from setup, including brief sole occupation during ordered battle losses and presence at turn boundaries. Earlier saves without that history, or histories with a Tupile qualifier whose continuing entitlement is unresolved, cannot make a new intelligence request. Already learned observations remain readable. Recorded qualification does not itself award occupied income or settle its expiry.', - 'During Spice Collection, Grumman with at least eight native reserves may add one available Terror token to an ordinary stronghold that already has Terror, then collect four bank spice. This opportunity is separate from Mentat placement and follows any pending shared spice allocation. Declining collects nothing. Removing a token remains unavailable while its destination after removal awaits a ruling.', + 'During Spice Collection, Grumman with at least eight native reserves may add one available Terror token to an ordinary stronghold already holding Terror, or return one unrevealed placed token to hidden supply, then collect four bank spice. Hidden supply identities rotate without revealing the returned face. This opportunity is separate from Mentat placement and follows pending shared spice allocation. Declining collects nothing. Under the user-selected Cunning interpretation, an ordinary eligible addition must first exist; an unallied Moritani holder may then spend its Nexus to broaden that addition’s destination to static Arrakis.', 'When several Terror tokens share a stronghold, Moritani privately selects one for an eligible entry, then chooses whether to reveal it or offer an alliance. The other tokens remain hidden. The board shows the public stack count; refresh preserves the selected token and original arrival.', 'While Junction has high population, Guild may offer another faction transport at half or full price during that faction’s shipment. The recipient can choose exact forces from one territory or Homeworld, including a return to their own world. An allied Homeworld remains forbidden. Half-price totals round up; five forces to a Homeworld cost three spice. The Junction route and offered rate resist Karama.', 'Homeworld shipment, battle and occupation have special rules. Occupiers receive the printed Collection income and card effects. An alliance can gain a special Homeworld victory through high-population Ecaz; this is separate from ordinary stronghold victory.', @@ -4194,6 +4194,10 @@ export const RULE_TOPICS: RuleTopic[] = [ 'tests/grumman-collection-engine.test.ts', 'tests/grumman-collection-controls.test.ts', 'tests/grumman-collection-recovery.test.ts', + 'tests/grumman-supply-return.test.ts', + 'tests/grumman-cunning-runtime.test.ts', + 'tests/nexus-moritani-grumman.test.ts', + 'tests/fixture-ruled-grumman.ts', 'tests/grumman-selection-integrity.test.ts', 'tests/homeworld-benefits-recovery.test.ts',], }, @@ -4569,7 +4573,7 @@ export const RULE_TOPICS: RuleTopic[] = [ '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.', 'CHOAM Cunning spends its Nexus card to use one held Treachery Card for one chosen Worthless effect at that effect’s normal timing, in Basic and Advanced. Choose the physical card separately from the effect. Kulon, La La La, Baliset, Jubba Cloak and Trip to Gamont retain their existing runtime. The explicit new Nexus Kull profile adds Kull at the canonical Karama reaction only; it remains unavailable in other Nexus previews. The old printed Kull profile still excludes Nexus. See the source-aware Kull guide for cost custody, counters and pending verification.', 'One Karama response covers the CHOAM effect. Cancellation leaves the chosen Treachery Card in hand and the Nexus spent. An allowed use discards the actual card once, retaining its printed identity and applicable discard consequences; it does not create a named Worthless card or a turn-long conversion permission.', - 'Moritani Cunning modifies its normal Mentat placement: choose one available supply token and a territory on the printed Arrakis board, including a territory already containing Terror. The explicit Cunning toggle keeps ordinary placement and relocation unchanged. Homeworlds, the Hidden Mobile Stronghold, Cunning relocation and combined Grumman use are unavailable.', + 'Moritani Cunning enhances supply placement only, not relocation, and never permits Homeworlds or the Hidden Mobile Stronghold. During native Mentat placement, choose one available supply token and a printed Arrakis territory, including one already holding Terror. The high-Grumman Collection controls additionally offer Cunning when an ordinary eligible supply addition to an existing stronghold stack is available. Cunning may then broaden the selected destination, but creates no underlying opportunity and does not spend the independent Mentat allowance.', 'With Moritani seated, an unallied holder of its Nexus card may spend Betrayal at a clean play boundary to return one placed Terror token to hidden Moritani supply without revealing its face. Choose the public location and token, not its secret effect; this does not use Moritani’s Mentat placement or trigger that token.', 'When Moritani is absent, a defeated combatant with an eligible publicly played card receives the same postbattle choice whether or not they hold Moritani Nexus. An unallied holder may spend it to retain one played card that a winner could keep; other played cards follow ordinary discard. Without a publicly eligible card there is no prompt. The choice is after the battle result and before later income/capture; the public prompt protects hidden custody and is an interface inference. This Secret Ally effect has no invented ordinary Karama response. Native Moritani allied retention remains a different, cancelable ability.', 'With actual Advanced Stronghold Cards, Arrakeen bank support changes the real payer debit, not personal income. Emperor Cunning temporary Sardaukar remain physically ordinary counters and retain ordinary Tanks custody. Original losing Moritani retention precedes mandatory original-winner Tech, then CHOAM inspection excludes used opposing cards even if retained. Ordinary city Collection remains separate; none of these windows repeats the original support, card cost or casualties.', diff --git a/tests/fixture-ruled-grumman.ts b/tests/fixture-ruled-grumman.ts new file mode 100644 index 00000000..b06f6add --- /dev/null +++ b/tests/fixture-ruled-grumman.ts @@ -0,0 +1,151 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, initializeHomeworldGameForAudit, initializeNexusGameForAudit, + joinGame, newPlayer, type Action, type Game, +} from '../game/engine'; +import { territory } from '../game/board'; +import { treacheryDeck } from '../game/cards'; +import { homeworldGameIntegrity } from '../game/homeworld-game'; +import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards'; +import { TERROR_KINDS } from '../game/moritani-terror'; +import { nextNativeE3HomeworldStep } from './fixture-homeworld-native-e3-modules'; + +export type RuledGrummanStep = { actor: string; action: Action }; +export type RuledGrummanFixture = { + game: Game; + owner: string; + placed: string; + placedTerritory: string; + initial: Game; + afterSetup: Game; + beforePlacement: Game; + afterPlacement: Game; + previousCollection: Game; + beforeCollection: Game; + drawing: Game | null; + afterDraw: Game | null; + census: { treachery: string[]; terror: string[]; nexus: string[] }; + actions: RuledGrummanStep[]; +}; + +/** Select only the original undealt lotteries, retaining all native stock. */ +function initialize(game: Game, nexus: boolean): Game { + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + crypto.getRandomValues = (array: T): T => { + assert.ok(array); + new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255); + return array; + }; + try { return nexus ? initializeNexusGameForAudit(game) : initializeHomeworldGameForAudit(game); } + finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } +} + +/** Move only still-unplayed Spice cards; never empty or redeal the discard. */ +function frontSpice(game: Game, kinds: ('land' | 'worm')[]): void { + for (const [position, kind] of kinds.entries()) { + const index = game.spiceDeck.findIndex((card, at) => at >= position && + (kind === 'land' ? 'territory' in card : 'worm' in card)); + assert.ok(index >= position); + game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]); + } +} + +export function assertRuledGrummanInventory(game: Game): void { + homeworldGameIntegrity(game); + for (const player of game.players) { + assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((a, b) => a + b, 0), 20); + if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks + + Object.values(player.elites.forces).reduce((a, b) => a + b, 0), 5); + } + assert.deepEqual([...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand), + ...(game.auction?.cards.slice(game.auction.index) ?? [])].map(c => c.id).sort(), + treacheryDeck(game.expansions).map(c => c.id).sort()); + assert.deepEqual(game.moritaniTerror!.tokens.map(t => t.kind).sort(), [...TERROR_KINDS].sort()); + assert.equal(new Set(game.moritaniTerror!.tokens.map(t => t.id)).size, 6); + if (game.nexusCards) validateNexusCards(game.nexusCards.cards!, game.players); +} + +/** Genuine setup → first Collection → Mentat placement → next Collection. + * No force, population, phase, turn, wallet, hand or earned receipt is staged. + * The Nexus variant plays an actual second-turn worm and original card draw. */ +export function createRuledGrummanFixture(options: { advanced?: boolean; nexus?: boolean } = {}): RuledGrummanFixture { + const owner = 'moritani', placedTerritory = 'arrakeen'; + let game = createGame('RULEDGRUMMAN', newPlayer(owner, 'Moritani', 'moritani'), options.advanced ?? false, options.nexus ? ['ecaz'] : []); + joinGame(game, newPlayer('guild', 'Guild', 'guild')); + joinGame(game, newPlayer('emperor', 'Emperor', 'emperor')); + game = applyAction(game, owner, { type: 'homeworlds', enabled: true }); + if (options.nexus) game.nexusCards = { cards: null, phase: null }; + const initial = structuredClone(game), actions: RuledGrummanStep[] = []; + function act(step: RuledGrummanStep): void { + actions.push(structuredClone(step)); + game = applyAction(game, step.actor, step.action); + } + for (const player of game.players) act({ actor: player.id, action: { type: 'ready' } }); + game = initialize(game, !!options.nexus); + let afterSetup: Game | undefined, previousCollection: Game | undefined; + let beforePlacement: Game | undefined, afterPlacement: Game | undefined, beforeCollection: Game | undefined; + let drawing: Game | null = null, afterDraw: Game | null = null, placed: string | undefined; + let spiceTurn = 0; + for (let count = 0; count < 500; count++) { + if (game.status === 'playing' && !afterSetup) { + afterSetup = structuredClone(game); + if (options.nexus) { + const deck = game.nexusCards!.cards!.deck; + const at = deck.indexOf('moritani'); assert.ok(at >= 0); + deck.unshift(deck.splice(at, 1)[0]); + } + } + if (game.status === 'playing' && game.phase === 0 && spiceTurn !== game.turn) { + frontSpice(game, options.nexus && game.turn === 2 ? ['worm', 'land', 'land'] : ['land', 'land']); + spiceTurn = game.turn; + } + if (options.nexus && game.nexus && !game.spiceWindow && !game.spiceResolution && + !game.response && !game.phaseOpening && !game.decision && !game.players.find(p => p.id === 'guild')!.ally) { + act({ actor: 'guild', action: { type: 'alliance', target: 'emperor' } }); + act({ actor: 'emperor', action: { type: 'alliance', target: 'guild' } }); + } + if (game.turn === 2 && game.decision?.kind === 'grummanCollection' && !game.response && !game.phaseOpening) break; + if (game.turn === 2 && game.phase === 5) beforeCollection = structuredClone(game); + const decision = game.decision; + let step: RuledGrummanStep | null; + if (!game.response && !game.phaseOpening && decision?.kind === 'moritaniSetup') { + step = { actor: owner, action: { type: 'decision', territory: 'habbanya_ridge_sietch', sector: territory('habbanya_ridge_sietch').sectors[0] } }; + } else if (!game.response && !game.phaseOpening && decision?.kind === 'grummanCollection') { + previousCollection = structuredClone(game); + step = { actor: owner, action: { type: 'decision', event: game.grummanCollection!.event, decline: true } }; + } else if (!game.response && !game.phaseOpening && decision?.kind === 'moritaniPlacement' && game.turn === 1) { + beforePlacement = structuredClone(game); + placed = game.moritaniTerror!.tokens.find(t => t.kind === 'robbery')!.id; + step = { actor: owner, action: { type: 'decision', token: placed, territory: placedTerritory } }; + } else if (!game.response && !game.phaseOpening && !decision && game.nexusCards?.phase?.stage === 'drawing') { + const actor = game.nexusCards.phase.eligible.find(id => !game.nexusCards!.phase!.done.includes(id)); assert.ok(actor); + if (actor === owner) drawing = structuredClone(game); + step = { actor, action: { type: 'nexusCardChoice', turn: game.turn, card: game.nexusCards.cards!.hands[actor] ?? null, + choice: actor === owner ? 'draw' : 'keep', ownRedraws: 0 } }; + } else step = nextNativeE3HomeworldStep(game); + assert.ok(step, `Native Grumman programme stalled at ${game.status}/${game.turn}/${game.phase}/${decision?.kind}`); + act(step); + if (beforePlacement && !afterPlacement && game.moritaniTerror!.tokens.some(t => t.id === placed && t.status === 'placed')) + afterPlacement = structuredClone(game); + if (drawing && !afterDraw && game.nexusCards!.cards!.hands[owner] === 'moritani') afterDraw = structuredClone(game); + } + assert.equal(game.turn, 2); assert.equal(game.phase, 7); assert.equal(game.decision?.kind, 'grummanCollection'); + assert.ok(afterSetup && previousCollection && beforePlacement && afterPlacement && beforeCollection && placed); + assert.equal(game.players.find(p => p.id === owner)!.reserves, 14); + assert.ok(!game.players.find(p => p.id === owner)!.ally); + assert.equal(game.moritaniTerror!.placementTurn, 1); + assert.equal(game.moritaniTerror!.tokens.find(t => t.id === placed)!.location, placedTerritory); + assert.ok(game.moritaniTerror!.tokens.some(t => t.status === 'available')); + if (options.nexus) { + assert.ok(drawing && afterDraw, `Native Nexus draw missing: ${JSON.stringify({ phase: game.nexusCards?.phase, discards: game.spiceDiscard, actions: actions.filter(s => s.action.type === 'nexusCardChoice') })}`); + assert.equal(game.nexusCards!.cards!.hands[owner], 'moritani'); + } + assertRuledGrummanInventory(game); + return { game, owner, placed, placedTerritory, initial, afterSetup, previousCollection, beforePlacement, afterPlacement, + beforeCollection, drawing, afterDraw, actions, + census: { treachery: treacheryDeck(game.expansions).map(c => c.id).sort(), terror: [...TERROR_KINDS].sort(), + nexus: options.nexus ? [...NEXUS_FACTIONS].sort() : [] } }; +} diff --git a/tests/grumman-collection-controls.test.ts b/tests/grumman-collection-controls.test.ts index a4d3734e..13ad1140 100644 --- a/tests/grumman-collection-controls.test.ts +++ b/tests/grumman-collection-controls.test.ts @@ -43,7 +43,9 @@ function fixture(): GameView { event: 'collection-1', player: 'm', blocked: null, - removeBlocked: 'Removing a token awaits a custody ruling.', + removeBlocked: 'No placed Terror token is available to return.', + removable: [], + cunning: null, tokens: [ { id: 'terror-1', kind: 'robbery' }, { id: 'terror-2', kind: 'sabotage' }, @@ -98,32 +100,6 @@ void test('public map markers group hidden Terror stacks without reading token f assert.doesNotMatch(html, /available|removed/); }); -void test('Grumman controls require an explicit available-token addition before the four-spice reward', () => { - const view = fixture(); - const before = structuredClone(view); - assert.deepEqual(grummanCollectionChoice(view, 'terror-2', 'carthag'), { - blocked: null, - action: { - type: 'decision', - event: 'collection-1', - mode: 'add', - token: 'terror-2', - territory: 'carthag', - }, - }); - const html = markup(view); - assert.match(html, /Your Terror token/); - assert.match(html, /Robbery/); - assert.match(html, /Sabotage/); - assert.match(html, /only after the addition resolves/); - assert.match(html, /Add token and collect 4 spice/); - assert.match( - html, - /Removal unavailable: Removing a token awaits a custody ruling/, - ); - assert.doesNotMatch(html, /]*>Remove/); - assert.deepEqual(view, before); -}); void test('stale tokens or destinations cannot add; blocked or exhausted collection can still be declined', () => { const view = fixture(); diff --git a/tests/grumman-collection-engine.test.ts b/tests/grumman-collection-engine.test.ts index 03e9ce4d..fb22fde8 100644 --- a/tests/grumman-collection-engine.test.ts +++ b/tests/grumman-collection-engine.test.ts @@ -122,7 +122,7 @@ void test('low seven waits without income and expires on Mentat; a current high stable(declined); }); -void test('owner event, physical availability, destination and pending removal rulings reject before token or spice mutation', () => { +void test('owner event, physical availability and destination reject before token or spice mutation', () => { const g = enterGrummanCollection(grummanCollectionFixture()); const event = g.grummanCollection!.event; const token = grummanToken(g, 'sabotage').id; @@ -135,7 +135,6 @@ void test('owner event, physical availability, destination and pending removal r territory: 'arrakeen', }, { event, mode: 'add', token, territory: 'carthag' }, - { event, mode: 'remove', token: grummanToken(g, 'robbery').id }, { event, decline: true, token }, ]) grummanReject(g, 'm', { type: 'decision', ...action }); diff --git a/tests/grumman-collection-recovery.test.ts b/tests/grumman-collection-recovery.test.ts index 36390a45..e80914ed 100644 --- a/tests/grumman-collection-recovery.test.ts +++ b/tests/grumman-collection-recovery.test.ts @@ -264,16 +264,10 @@ void test('production SQL lost Grumman add response preserves four spice and ind } finally { f.sqlite.close(); } }); -void test('production SQL guarded removal and declined Grumman choice never pay or change token custody', async () => { +void test('production SQL declined Grumman choice never pays or changes token custody', async () => { const f = await persisted(); try { const g = await opened(f); - const before = snapshot(f); - await assert.rejects(f.rooms.act(f.code, f.auths[0], g.version, { - type: 'decision', event: g.grummanCollection!.event, mode: 'remove', token: token(g, 'robbery').id, - }, clock)); - assert.deepEqual(snapshot(f), before); - assert.equal(f.writes.length, 0); const done = await act(f, g, f.ids[0], { type: 'decision', event: g.grummanCollection!.event, decline: true }); assert.equal(done.grummanCollection!.outcome, 'decline'); assert.deepEqual(done.moritaniTerror, g.moritaniTerror); diff --git a/tests/grumman-collection.test.ts b/tests/grumman-collection.test.ts index 59c66197..db428c25 100644 --- a/tests/grumman-collection.test.ts +++ b/tests/grumman-collection.test.ts @@ -137,7 +137,7 @@ void test('offers need available supply and an existing ordinary stronghold toke assert.match(quoteGrummanCollection(g, 'm').blocked!, /no available/); }); -void test('decline gives zero with cloned unchanged custody; unresolved removal never spends or reveals a token', () => { +void test('decline gives zero with cloned unchanged custody; removal requires current high population and a placed token', () => { for (const native of [7, 8]) { const g = fixture(native); const before = JSON.stringify(g); @@ -145,12 +145,17 @@ void test('decline gives zero with cloned unchanged custody; unresolved removal assert.equal(declined.amount, 0); assert.deepEqual(declined.state, g.moritaniTerror); assert.notEqual(declined.state, g.moritaniTerror); - assert.match(quoteGrummanCollection(g, 'm').removeBlocked, /custody zone/); - for (const token of ['terror-1', 'terror-2', 'atomics-aftermath', 'absent']) - assert.throws( - () => quoteGrummanCollectionAction(g, 'm', { mode: 'remove', token }), - /custody zone/, - ); + assert.equal(quoteGrummanCollection(g, 'm').removeBlocked === null, native >= 8); + for (const token of ['terror-2', 'atomics-aftermath', 'absent']) + assert.throws(() => quoteGrummanCollectionAction(g, 'm', { mode: 'remove', token })); + if (native < 8) + assert.throws(() => quoteGrummanCollectionAction(g, 'm', { mode: 'remove', token: 'terror-1' })); + else { + const returned = quoteGrummanCollectionAction(g, 'm', { mode: 'remove', token: 'terror-1' }, () => 0); + assert.equal(returned.amount, 4); + assert.equal(returned.state.tokens.filter(token => token.status === 'available').length, 6); + assert.equal(returned.state.placementTurn, 2); + } assert.equal(JSON.stringify(g), before); } }); diff --git a/tests/grumman-cunning-runtime.test.ts b/tests/grumman-cunning-runtime.test.ts new file mode 100644 index 00000000..01b62688 --- /dev/null +++ b/tests/grumman-cunning-runtime.test.ts @@ -0,0 +1,132 @@ +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 { DIFFICULTIES } from '../game/bot-profiles'; +import { grummanCollectionChoice } from '../game/grumman-collection-options'; +import { assertRuledGrummanInventory, createRuledGrummanFixture } from './fixture-ruled-grumman'; +import { nextNativeE3HomeworldStep } from './fixture-homeworld-native-e3-modules'; + +const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision; +function advance(state: Game, until: (game: Game) => boolean): Game { + let game = state; + for (let count = 0; count < 900; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished'); + if (game.decision?.kind === 'moritaniPlacement' && !game.response && !game.phaseOpening) + game = applyAction(game, game.decision.player, {type:'decision',decline:true}); + else { + const step = nextNativeE3HomeworldStep(game); + assert.ok(step); + game = applyAction(game,step.actor,step.action); + } + } + throw Error('Native Grumman Cunning programme missed its boundary'); +} +function reject(game: Game, actor: string, action: Action): void { + const before = structuredClone(game); + assert.throws(()=>applyAction(game,actor,action)); + assert.deepEqual(game,before); +} +function cunningFixture(advanced = false) { + const f = createRuledGrummanFixture({advanced,nexus:true}); + const token = f.game.moritaniTerror!.tokens.find(t=>t.status==='available'&&t.kind==='sabotage')!; + const view = viewGame(f.game,f.owner); + const action = grummanCollectionChoice(view,token.id,'polar_sink','add',true).action; + assert.ok(action); + return {...f,token,action}; +} + +void test('native high-Grumman Cunning adds from supply to an empty static territory using its actual ordinary prerequisite',()=>{ + for (const advanced of [false,true]) { + const f = cunningFixture(advanced); + const before = f.game; + const wallet = before.players.find(p=>p.id===f.owner)!.spice; + const armies = before.players.map(p=>({id:p.id,forces:p.forces,reserves:p.reserves,tanks:p.tanks,elites:p.elites})); + reject(before,'guild',f.action); + reject(before,f.owner,{...f.action,nexus:'old-cunning-opportunity'}); + reject(before,f.owner,{...f.action,token:f.placed}); + reject(before,f.owner,{...f.action,territory:'hidden_mobile_stronghold'}); + reject(before,f.owner,{...f.action,territory:'homeworld:moritani'}); + const game = applyAction(before,f.owner,f.action); + assert.equal(game.players.find(p=>p.id===f.owner)!.spice,wallet+4); + assert.deepEqual(game.players.map(p=>({id:p.id,forces:p.forces,reserves:p.reserves,tanks:p.tanks,elites:p.elites})),armies); + assert.equal(game.moritaniTerror!.placementTurn,before.moritaniTerror!.placementTurn); + assert.deepEqual(game.moritaniTerror!.tokens.find(t=>t.id===f.token.id),{...f.token,status:'placed',location:'polar_sink'}); + assert.deepEqual(game.moritaniTerror!.tokens.find(t=>t.id===f.placed),before.moritaniTerror!.tokens.find(t=>t.id===f.placed)); + const receipt = game.nexusMoritaniHistory!.at(-1)!.receipt; + assert.equal(receipt.source,'grumman'); + if(receipt.source!=='grumman')throw Error('Expected the original Grumman source'); + assert.equal(receipt.sourceEvent,before.grummanCollection!.event); + assert.equal(receipt.prerequisite,f.placedTerritory); + assert.equal(game.nexusCards!.cards!.hands[f.owner],null); + assert.equal(game.nexusCards!.cards!.discard.filter(card=>card==='moritani').length,1); + assert.equal(game.grummanCollection!.nexusEvent,receipt.event); + for(const p of game.players.filter(p=>p.id!==f.owner)){ + const view = viewGame(game,p.id); + assert.ok(view.moritaniTerror!.tokens.every(t=>!('kind' in t))); + assert.equal(Object.hasOwn(view,'nexusMoritaniHistory'),false); + } + reject(game,f.owner,f.action); + assertRuledGrummanInventory(game); + } +}); + +void test('a Grumman Cunning location authorizes a later real entry and its unrevealed return retires only that location',()=>{ + const f = cunningFixture(); + let game = applyAction(f.game,f.owner,f.action); + game = advance(game,g=>g.turn===3&&g.phase===5&&g.active==='guild'&&clean(g)); + const beforeShipment = structuredClone(game); + const payer = game.players.find(p=>p.id==='guild')!; + game = applyAction(game,'guild',{type:'ship',territory:'polar_sink',sector:0,amount:1,elite:0, + homeworldSources:{'homeworld:guild':{normal:1,elite:0}}}); + assert.equal(game.pendingTerrorEntry!.token,f.token.id); + assert.equal(game.pendingTerrorEntry!.territory,'polar_sink'); + assert.equal(game.players.find(p=>p.id==='guild')!.spice,payer.spice-1); + assert.equal(game.players.find(p=>p.id==='guild')!.forces['polar_sink:0'],1); + assert.equal(game.players.find(p=>p.id==='guild')!.reserves,payer.reserves-1); + game = applyAction(game,f.owner,{type:'decision',decline:true}); + assert.equal(game.moritaniTerror!.tokens.find(t=>t.id===f.token.id)!.status,'placed'); + assert.equal(game.players.find(p=>p.id==='guild')!.spice,beforeShipment.players.find(p=>p.id==='guild')!.spice-1); + game = advance(game,g=>g.turn===3&&g.decision?.kind==='grummanCollection'); + const balance = game.players.find(p=>p.id===f.owner)!.spice; + game = applyAction(game,f.owner,{type:'decision',event:game.grummanCollection!.event,mode:'remove',token:f.token.id}); + assert.equal(game.players.find(p=>p.id===f.owner)!.spice,balance+4); + assert.equal(game.nexusMoritaniLocations![f.token.id],undefined); + assert.equal(game.moritaniTerror!.tokens.some(t=>t.id===f.token.id),false); + assert.equal(game.moritaniTerror!.tokens.find(t=>t.id===f.placed)!.location,f.placedTerritory); + assert.equal(game.moritaniTerror!.tokens.find(t=>t.kind===f.token.kind)!.status,'available'); + assertRuledGrummanInventory(game); +}); + +void test('held Cunning cannot manufacture a Grumman addition after the only board token actually returned',()=>{ + const f = createRuledGrummanFixture({nexus:true}); + let game = applyAction(f.game,f.owner,{type:'decision',event:f.game.grummanCollection!.event,mode:'remove',token:f.placed}); + game = advance(game,g=>g.turn===3&&g.decision?.kind==='grummanCollection'); + assert.equal(game.nexusCards!.cards!.hands[f.owner],'moritani'); + const view = viewGame(game,f.owner), offer = view.grummanCollection!; + assert.ok(offer.cunning!.blocked); + assert.equal(offer.cunning!.prerequisite,null); + const token = offer.tokens[0].id; + assert.equal(grummanCollectionChoice(view,token,'polar_sink','add',true).action,null); + reject(game,f.owner,{type:'decision',event:offer.event,mode:'add',token,territory:'polar_sink',nexus:offer.cunning!.event}); + const declined = applyAction(game,f.owner,{type:'decision',event:offer.event,decline:true}); + assert.equal(declined.players.find(p=>p.id===f.owner)!.spice,game.players.find(p=>p.id===f.owner)!.spice); + assert.equal(declined.nexusCards!.cards!.hands[f.owner],'moritani'); +}); + +void test('all existing legal policies resolve the owned Grumman Cunning source without reading hidden opposing faces',()=>{ + const f = cunningFixture(); + for(const difficulty of DIFFICULTIES){ + const view = viewGame(f.game,f.owner); + view.players.find(p=>p.id===f.owner)!.bot = difficulty; + const action = botActions(view)[0]; + assert.ok(action); + const done = applyAction(f.game,f.owner,action); + assert.equal(done.players.find(p=>p.id===f.owner)!.spice,f.game.players.find(p=>p.id===f.owner)!.spice+4); + assert.equal(done.grummanCollection!.stage,'complete'); + assert.equal(done.nexusCards!.cards!.hands[f.owner],null); + assert.equal(done.moritaniTerror!.placementTurn,f.game.moritaniTerror!.placementTurn); + assertRuledGrummanInventory(done); + } +}); diff --git a/tests/grumman-supply-return.test.ts b/tests/grumman-supply-return.test.ts new file mode 100644 index 00000000..f4bc01f7 --- /dev/null +++ b/tests/grumman-supply-return.test.ts @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { quoteGrummanCollection, quoteGrummanCollectionAction } from '../game/grumman-collection'; +import { grummanCollectionSignature, validateGrummanCollection } from '../game/grumman-collection-return'; +import { placeTerror, projectTerror, revealTerror, TERROR_KINDS } from '../game/moritani-terror'; +import { nextNativeE3HomeworldStep } from './fixture-homeworld-native-e3-modules'; +import { assertRuledGrummanInventory, createRuledGrummanFixture } from './fixture-ruled-grumman'; + +function reject(game: Game, actor: string, action: Action): void { + const before = structuredClone(game); + assert.throws(() => applyAction(game, actor, action)); + assert.deepEqual(game, before); +} + +void test('native Grumman removal returns the unrevealed face to rotated supply, credits four spice once and preserves Mentat use', () => { + for (const advanced of [false, true]) { + const f = createRuledGrummanFixture({ advanced }); + const original = structuredClone(f.game); + const prior = f.game.moritaniTerror!; + const face = prior.tokens.find(t => t.id === f.placed)!.kind; + const owner = f.game.players.find(p => p.id === f.owner)!; + const request: Action = { type: 'decision', event: f.game.grummanCollection!.event, mode: 'remove', token: f.placed }; + reject(f.game, 'guild', request); + for (const token of [prior.tokens.find(t => t.status === 'available')!.id, 'missing']) + reject(f.game, f.owner, { ...request, token }); + let game = applyAction(f.game, f.owner, request); + assert.deepEqual(f.game, original); + assert.equal(game.players.find(p => p.id === f.owner)!.spice, owner.spice + 4); + assert.equal(game.grummanCollection!.stage, 'complete'); + assert.equal(game.grummanCollection!.outcome, 'remove'); + assert.equal(game.grummanCollection!.token, f.placed); + assert.equal(game.grummanCollection!.territory, f.placedTerritory); + assert.equal(game.moritaniTerror!.placementTurn, prior.placementTurn); + assert.equal(game.moritaniTerror!.supplyEpoch, (prior.supplyEpoch ?? 0) + 1); + assert.deepEqual(game.moritaniTerror!.tokens.map(t => t.kind).sort(), [...TERROR_KINDS].sort()); + assert.ok(game.moritaniTerror!.tokens.every(t => t.status === 'available' && t.location === null)); + assert.ok(game.moritaniTerror!.tokens.every(t => !prior.tokens.some(old => old.id === t.id))); + assert.deepEqual(projectTerror(game.moritaniTerror!, false).tokens, []); + assertRuledGrummanInventory(game); + validateGrummanCollection(game); + reject(game, f.owner, request); + reject(game, f.owner, { ...request, mode: 'add', token: game.moritaniTerror!.tokens[0].id, territory: f.placedTerritory }); + for (let count = 0; game.decision?.kind !== 'moritaniPlacement' && count < 100; count++) { + const step = nextNativeE3HomeworldStep(game); assert.ok(step); + game = applyAction(game, step.actor, step.action); + } + assert.equal(game.decision?.kind, 'moritaniPlacement'); + const returned = game.moritaniTerror!.tokens.find(t => t.kind === face)!; + assert.notEqual(returned.id, f.placed); + game = applyAction(game, f.owner, { type: 'decision', token: returned.id, territory: 'carthag' }); + for (let count = 0; game.response && count < 20; count++) { + const step = nextNativeE3HomeworldStep(game); assert.ok(step); + game = applyAction(game, step.actor, step.action); + } + assert.equal(game.moritaniTerror!.placementTurn, 2); + const redeployed = projectTerror(game.moritaniTerror!, false).tokens.find(t => t.id === returned.id)!; + assert.deepEqual(redeployed, { id: returned.id, location: 'carthag', status: 'placed' }); + assertRuledGrummanInventory(game); + assert.ok(!JSON.stringify(viewGame(game, 'guild').moritaniTerror).includes(f.placed)); + } +}); + +void test('return quote remains available without ADD supply and preserves other placements and spent faces', () => { + const f = createRuledGrummanFixture(); + const game = structuredClone(f.game); + // Pure custody input: all faces are physically placed, so no addition exists. + game.moritaniTerror = placeTerror(game.moritaniTerror!, game.moritaniTerror!.tokens.find(t => t.kind === 'sabotage')!.id, 'carthag', 2); + for (const token of game.moritaniTerror.tokens) + if (token.status === 'available') { token.status = 'placed'; token.location = 'carthag'; } + const quote = quoteGrummanCollection(game, f.owner); + assert.ok(quote.blocked); + assert.equal(quote.removeBlocked, null); + assert.equal(quote.removable.length, 6); + quote.removable[0].location = 'not-a-location'; + assert.notEqual(game.moritaniTerror.tokens[0].location, 'not-a-location'); + const before = structuredClone(game); + const result = quoteGrummanCollectionAction(game, f.owner, { mode: 'remove', token: f.placed }, () => 0); + assert.equal(result.amount, 4); + assert.equal(result.state.placementTurn, 2); + assert.deepEqual(result.state.tokens.filter(t => t.status === 'placed'), before.moritaniTerror!.tokens.filter(t => t.id !== f.placed)); + assert.equal(result.state.tokens.filter(t => t.status === 'available').length, 1); + assert.deepEqual(game, before); + for (const kind of ['robbery', 'extortion'] as const) { + const source = structuredClone(f.game); + const selected = source.moritaniTerror!.tokens.find(t => t.kind === kind)!; + if (selected.status === 'available') source.moritaniTerror = placeTerror(source.moritaniTerror!, selected.id, 'carthag', 2); + source.moritaniTerror = revealTerror(source.moritaniTerror!, selected.id); + const invalid = structuredClone(source); + assert.throws(() => quoteGrummanCollectionAction(source, f.owner, { mode: 'remove', token: selected.id }, () => 0)); + assert.deepEqual(source, invalid); + if (kind === 'extortion') { + const returned = quoteGrummanCollectionAction(source, f.owner, { mode: 'remove', token: f.placed }, () => 0); + assert.deepEqual(returned.state.tokens.find(t => t.id === selected.id), source.moritaniTerror.tokens.find(t => t.id === selected.id)); + } + } +}); + +void test('stale rotated IDs, inactive Grumman, low native population and foreign owners cannot quote a return', () => { + const f = createRuledGrummanFixture(); + const action = { mode: 'remove', token: f.placed } as const; + const result = quoteGrummanCollectionAction(f.game, f.owner, action, () => 0); + const returned = { ...f.game, moritaniTerror: result.state }; + assert.throws(() => quoteGrummanCollectionAction(returned, f.owner, action, () => 0)); + for (const context of [ + { ...f.game, homeworlds: undefined }, + { ...f.game, players: f.game.players.map(p => p.id === f.owner ? { ...p, reserves: 7 } : p) }, + ]) { + const before = structuredClone(context); + assert.ok(quoteGrummanCollection(context, f.owner).removeBlocked); + assert.deepEqual(quoteGrummanCollection(context, f.owner).removable, []); + assert.throws(() => quoteGrummanCollectionAction(context, f.owner, action, () => 0)); + assert.deepEqual(context, before); + } + assert.throws(() => quoteGrummanCollectionAction(f.game, 'guild', action, () => 0)); + assert.throws(() => quoteGrummanCollectionAction(f.game, f.owner, { ...action, token: 'missing' }, () => 0)); +}); + +void test('native removal historical frame binds its original ID and territory without requiring a live token', () => { + const f = createRuledGrummanFixture(); + const game = applyAction(f.game, f.owner, { type: 'decision', event: f.game.grummanCollection!.event, mode: 'remove', token: f.placed }); + assert.ok(!game.moritaniTerror!.tokens.some(t => t.id === f.placed)); + validateGrummanCollection(game); + for (const changes of [ + { territory: 'homeworld:moritani' }, { territory: 'mobile_stronghold' }, { token: undefined }, + { nexusEvent: 'unearned' }, { outcome: 'decline' as const }, + ]) { + const bad = structuredClone(game); + Object.assign(bad.grummanCollection!, changes); + bad.grummanCollection!.signature = grummanCollectionSignature(bad.grummanCollection!); + assert.throws(() => validateGrummanCollection(bad)); + } +}); diff --git a/tests/nexus-moritani-controls.test.ts b/tests/nexus-moritani-controls.test.ts index 5b8f56c7..02e74bb8 100644 --- a/tests/nexus-moritani-controls.test.ts +++ b/tests/nexus-moritani-controls.test.ts @@ -66,10 +66,6 @@ void test('genuine Basic and Advanced Mentat keeps native relocation choices and assert.doesNotMatch(markup, /checked=""/); assert.match(markup, /decision-checkbox min-h-11/); assert.match(markup, /Use Moritani Nexus Cunning/); - assert.match( - markup, - /Relocation, Grumman use, Homeworlds and the Hidden Mobile Stronghold are unavailable/, - ); assert.match(markup, /Destination stronghold/); assert.match(html(v, true), /disabled=""/); assert.match( diff --git a/tests/nexus-moritani-grumman.test.ts b/tests/nexus-moritani-grumman.test.ts new file mode 100644 index 00000000..25cec8f6 --- /dev/null +++ b/tests/nexus-moritani-grumman.test.ts @@ -0,0 +1,225 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { TERRITORIES, MOBILE_STRONGHOLD } from '../game/board'; +import { + createTerrorState, + placeTerror, + revealTerror, + type TerrorState, +} from '../game/moritani-terror'; +import { + createNexusMoritani, + validateNexusMoritani, + quoteNexusMoritaniPlacement, + quoteNexusMoritaniGrummanPlacement, + type NexusMoritaniContext, + type NexusMoritaniGrummanSource, + type NexusMoritaniReceipt, +} from '../game/nexus-moritani'; + +const context: NexusMoritaniContext = { + turn: 3, + players: [ + { id: 'm', faction: 'moritani' }, + { id: 'a', faction: 'atreides' }, + ], +}; +const source: NexusMoritaniGrummanSource = { + source: 'grumman', + sourceEvent: JSON.stringify(['grummanCollection', 3, 'm']), + prerequisite: 'arrakeen', +}; +const clone = (value: T): T => JSON.parse(JSON.stringify(value)); +const fresh = () => createTerrorState(() => 0.3); +const eligible = (turn = 1) => { + const state = fresh(); + return placeTerror(state, state.tokens[0].id, 'arrakeen', turn); +}; +const grummanEvent = (receipt: Extract) => + JSON.stringify([ + 'nexusMoritaniGrumman', + receipt.turn, + receipt.owner, + receipt.token, + receipt.territory, + receipt.sourceEvent, + receipt.prerequisite, + ]); + +void test('Grumman Cunning receipts bind the native source and ordinary prerequisite to their event and signature', () => { + const receipt = createNexusMoritani(context, 'm', 'supply-token', 'polar_sink', source); + assert.equal(receipt.source, 'grumman'); + if (receipt.source !== 'grumman') throw new Error('Expected Grumman receipt'); + assert.equal(receipt.phase, 7); + assert.equal(receipt.event, grummanEvent(receipt)); + validateNexusMoritani({ ...context, turn: 8 }, clone(receipt)); + assert.notEqual( + receipt.event, + createNexusMoritani(context, 'm', 'supply-token', 'polar_sink', { + ...source, + sourceEvent: 'another-native-operation', + }).event, + ); + assert.notEqual( + receipt.event, + createNexusMoritani(context, 'm', 'supply-token', 'polar_sink', { + ...source, + prerequisite: 'carthag', + }).event, + ); + const edits: ((r: Extract) => void)[] = [ + (r) => { r.sourceEvent = 'other-source'; }, + (r) => { r.prerequisite = 'carthag'; }, + (r) => { r.sourceEvent = 'other-source'; r.event = grummanEvent(r); }, + (r) => { r.prerequisite = 'carthag'; r.event = grummanEvent(r); }, + (r) => { r.token = 'other-token'; }, + (r) => { r.territory = 'hagga_basin'; }, + (r) => { r.turn = 2; }, + (r) => { r.owner = 'a'; }, + (r) => { Object.assign(r, { source: 'mentat', phase: 8 }); }, + (r) => { Object.assign(r, { phase: 8 }); }, + (r) => { delete (r as Partial>).sourceEvent; }, + (r) => { delete (r as Partial>).prerequisite; }, + (r) => { r.signature = ''; }, + ]; + for (const edit of edits) { + const bad: Extract = clone(receipt); + edit(bad); + const before: typeof bad = clone(bad); + assert.throws(() => validateNexusMoritani(context, bad)); + assert.deepEqual(bad, before); + } + assert.throws(() => validateNexusMoritani({ ...context, turn: 2 }, receipt)); + for (const prerequisite of ['polar_sink', MOBILE_STRONGHOLD, 'homeworld:moritani', '']) + assert.throws(() => createNexusMoritani(context, 'm', 'token', 'polar_sink', { + ...source, + prerequisite, + })); + assert.throws(() => createNexusMoritani(context, 'm', 'token', 'polar_sink', { + ...source, + sourceEvent: '', + })); +}); + +void test('Existing Mentat evidence authenticates independently of the new Collection source', () => { + const legacy: NexusMoritaniReceipt = { + version: 1, + event: JSON.stringify(['nexusMoritani', 3, 'm', 'token', 'hagga_basin']), + source: 'mentat', + phase: 8, + turn: 3, + owner: 'm', + token: 'token', + territory: 'hagga_basin', + roster: [{ id: 'a', faction: 'atreides' }, { id: 'm', faction: 'moritani' }], + signature: '', + }; + legacy.signature = JSON.stringify([ + 1, legacy.event, 'mentat', 8, 3, 'm', 'token', 'hagga_basin', + [['a', 'atreides'], ['m', 'moritani']], + ]); + validateNexusMoritani(context, legacy); + const current = createNexusMoritani(context, 'm', 'token', 'hagga_basin'); + assert.equal(current.event, legacy.event); + assert.equal(current.signature, legacy.signature); + validateNexusMoritani(context, current); + assert.throws(() => validateNexusMoritani(context, { + ...legacy, + source: 'grumman', + phase: 7, + sourceEvent: source.sourceEvent, + prerequisite: source.prerequisite, + })); +}); + +void test('Grumman Cunning broadens an available ordinary addition to every printed static territory, including empty desert and stacks', () => { + const state = eligible(); + const before = clone(state); + const id = state.tokens[1].id; + for (const territory of TERRITORIES) { + const placed = quoteNexusMoritaniGrummanPlacement(state, id, territory.id, 'arrakeen'); + assert.equal(placed.tokens[1].status, 'placed'); + assert.equal(placed.tokens[1].location, territory.id); + assert.equal(placed.placementTurn, state.placementTurn); + assert.deepEqual(placed.tokens.filter((token) => token.id !== id), state.tokens.filter((token) => token.id !== id)); + if (territory.id === 'arrakeen') + assert.equal(placed.tokens.filter((token) => token.location === 'arrakeen').length, 2); + } + assert.deepEqual(state, before); + const desert = quoteNexusMoritaniGrummanPlacement(state, id, 'hagga_basin', 'arrakeen'); + const stacked = quoteNexusMoritaniGrummanPlacement(desert, desert.tokens[2].id, 'hagga_basin', 'arrakeen'); + assert.equal(stacked.tokens.filter((token) => token.location === 'hagga_basin').length, 2); +}); + +void test('Grumman Cunning requires an actually eligible ordinary stronghold before broadening the destination', () => { + const empty = fresh(); + const desertOnly = quoteNexusMoritaniPlacement(empty, empty.tokens[0].id, 'hagga_basin', 1); + const ordinary = eligible(); + for (const [state, prerequisite] of [ + [empty, 'arrakeen'], + [desertOnly, 'arrakeen'], + [desertOnly, 'hagga_basin'], + [ordinary, 'carthag'], + [ordinary, MOBILE_STRONGHOLD], + [ordinary, 'homeworld:moritani'], + ] as const) { + const before = clone(state); + assert.throws(() => quoteNexusMoritaniGrummanPlacement(state, state.tokens[1].id, 'polar_sink', prerequisite)); + assert.deepEqual(state, before); + } +}); + +void test('Grumman Cunning cannot create supply, reuse revealed tokens or enhance relocation, and rejects excluded destinations', () => { + const state = eligible(); + const revealed = revealTerror(state, state.tokens[0].id); + for (const [stock, id] of [ + [state, 'invented-token'], + [state, state.tokens[0].id], + [revealed, revealed.tokens[0].id], + ] as const) { + const before = clone(stock); + assert.throws(() => quoteNexusMoritaniGrummanPlacement(stock, id, 'polar_sink', 'arrakeen')); + assert.deepEqual(stock, before); + } + for (const territory of [MOBILE_STRONGHOLD, 'homeworld:moritani', 'arrakeen:10', '', '__proto__']) { + assert.throws(() => quoteNexusMoritaniGrummanPlacement(state, state.tokens[1].id, territory, 'arrakeen')); + assert.throws(() => createNexusMoritani(context, 'm', state.tokens[1].id, territory, source)); + } + const edits: ((stock: TerrorState) => void)[] = [ + (stock) => { stock.tokens.pop(); }, + (stock) => { stock.tokens[2].id = stock.tokens[1].id; }, + (stock) => { stock.tokens[2].kind = stock.tokens[1].kind; }, + (stock) => { stock.tokens[1].location = 'arrakeen'; }, + (stock) => { stock.supplyEpoch = -1; }, + ]; + for (const edit of edits) { + const bad = clone(state); + edit(bad); + const before = clone(bad); + assert.throws(() => quoteNexusMoritaniGrummanPlacement(bad, state.tokens[1].id, 'polar_sink', 'arrakeen')); + assert.deepEqual(bad, before); + } +}); + +void test('Grumman Cunning preserves the physical faces, other placements, supply epoch and independent Mentat allowance', () => { + let state = eligible(); + state = quoteNexusMoritaniPlacement(state, state.tokens[1].id, 'hagga_basin', 2); + state.supplyEpoch = 4; + const before = clone(state); + const placed = quoteNexusMoritaniGrummanPlacement(state, state.tokens[2].id, 'polar_sink', 'arrakeen'); + assert.deepEqual(placed.tokens.map(({ id, kind }) => ({ id, kind })), state.tokens.map(({ id, kind }) => ({ id, kind }))); + assert.deepEqual(placed.tokens.filter((token) => token.id !== state.tokens[2].id), state.tokens.filter((token) => token.id !== state.tokens[2].id)); + assert.equal(placed.supplyEpoch, 4); + assert.equal(placed.placementTurn, 2); + const mentat = quoteNexusMoritaniPlacement(placed, placed.tokens[3].id, 'carthag', 3); + assert.equal(mentat.placementTurn, 3); + assert.equal(mentat.tokens[2].location, 'polar_sink'); + placed.tokens[0].location = 'carthag'; + placed.tokens[4].status = 'removed'; + assert.deepEqual(state, before); + + const alreadySpent = eligible(3); + const collection = quoteNexusMoritaniGrummanPlacement(alreadySpent, alreadySpent.tokens[1].id, 'hagga_basin', 'arrakeen'); + assert.equal(collection.placementTurn, 3); + assert.throws(() => quoteNexusMoritaniPlacement(collection, collection.tokens[2].id, 'carthag', 3)); +});