@@ -67,11 +76,37 @@ export function HomeworldShipment({
Allied Homeworlds cannot be destinations.
- {me.faction === 'guild'
- ? 'Guild pays one spice per two forces, rounded up.'
- : 'Pay one spice per force.'}{' '}
+ {useNexus
+ ? 'Selected Guild Nexus price: one spice per two forces, rounded up.'
+ : me.faction === 'guild'
+ ? 'Guild pays one spice per two forces, rounded up.'
+ : 'Pay one spice per force.'}{' '}
This uses your shipment; your normal movement remains available.
+ {g.nexusGuildSecretAlly && (
+
+
+
+ setNexusEvent(
+ event.target.checked ? g.nexusGuildSecretAlly!.event : '',
+ )
+ }
+ />
+ Use Guild Nexus Secret Ally for Homeworld shipment
+
+
+ Spend the Nexus to pay one spice per two forces, rounded up.
+ Existing source and destination restrictions still apply.
+
+ {g.nexusGuildSecretAlly.blocked && (
+
{g.nexusGuildSecretAlly.blocked}
+ )}
+
+ )}
{option.blocked && (
{option.blocked}
)}
diff --git a/components/nexus-cards.tsx b/components/nexus-cards.tsx
index 389da783..00bdb4f9 100644
--- a/components/nexus-cards.tsx
+++ b/components/nexus-cards.tsx
@@ -118,7 +118,7 @@ export function NexusCards({ game, act, busy }: { game: GameView; act: (action:
Nexus cards
{offer.deckCount} in deck · {offer.discardCount} discarded · {Object.values(offer.held).filter((held) => held === true).length} held
-
Playable effects include Atreides inspections, Harkonnen exchanges, and Tleilaxu, Ixian, Bene Gesserit, Emperor, CHOAM and Moritani Cunning, plus Richese Secret Ally reserve shipping and Guild Cunning’s second shipment. Use the controls shown at each effect’s timing. CHOAM’s Kull Wahad effect, Moritani Cunning relocation and Grumman combinations, and other unfinished effects remain unavailable.
+
Playable effects include Atreides inspections, Harkonnen exchanges, and Tleilaxu, Ixian, Bene Gesserit, Emperor, CHOAM and Moritani Cunning, plus Richese Secret Ally reserve shipping and Guild Cunning’s second shipment or absent-Guild Secret Ally shipping. Use the controls shown at each effect’s timing. CHOAM’s Kull Wahad effect, Moritani Cunning relocation and Grumman combinations, and other unfinished effects remain unavailable.
{game.nexusAtreides && offer.card === 'atreides' && (
diff --git a/docs/COMPONENT_INVENTORY.md b/docs/COMPONENT_INVENTORY.md
index 42f5fc1b..f3c9575a 100644
--- a/docs/COMPONENT_INVENTORY.md
+++ b/docs/COMPONENT_INVENTORY.md
@@ -1,5 +1,7 @@
# Component inventory and verification ledger
+**Guild Nexus Secret Ally update, 10 September 2026:** the existing card now supplies an explicit paid shipment choice, including physical cross/return selection and typed reserve or world-to-world sources. The holder keeps its own faction identity, forces and ordinary movement. No component is duplicated; existing full-card inspection remains shared. [NEXUS_GUILD_SECRET_ALLY_RUNTIME.md](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) records the supported routes and verification. Homeworld-return interpretation, concealed transport, Betrayal and complete module acceptance remain unfinished.
+
**Guild Nexus Cunning update, 10 September 2026:** the existing native card now offers an immediate second paid shipment after the original combined turn. No new forces, markers or cards are created; the normal physical reserve, cross/return and Homeworld controls retain their source counts and price. A separate skip choice and unused Hajr control preserve actual movement history. [NEXUS_GUILD_CUNNING_RUNTIME.md](NEXUS_GUILD_CUNNING_RUNTIME.md) records runtime, interruption and recovery evidence. Guild Secret Ally, Betrayal, physical Ornithopter combinations and full module acceptance remain unfinished.
**Richese Nexus Secret Ally update, 10 September 2026:** the existing held card now has a shipment choice that moves up to five actual counters at the one-counter price. It creates no No-Field marker, changes no component identity and retains typed Homeworld reserves and native Ixian direct HMS entry. Controls distinguish physical forces from charged forces, with the original card inspector unchanged. [NEXUS_RICHESE_RUNTIME.md](NEXUS_RICHESE_RUNTIME.md) records scope, recovery and verification; native two-marker Cunning, Betrayal and complete module acceptance remain unfinished.
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index 0d0926e1..2d140681 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,7 @@
# Dune implementation status
+**Guild Nexus Secret Ally checkpoint, 10 September 2026:** [NEXUS_GUILD_SECRET_ALLY_RUNTIME.md](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) records one explicitly selected Guild-priced shipment for an unallied holder with Guild absent. Reserve, physical cross/abstract-return and independently legal world-to-world routes preserve actual faction identity, typed custody, ordinary movement and bank payment. Fremen retain their free reinforcement choice; the separate paid southern route reaches beyond its range without storm privileges or off-planet triggers. Private controls and all four bot profiles share source-aware price/permission; binding shipment promises can rely on the card. Exact committed records and an independent outcome marker preserve arrival alliances and later movement without inventing native Guild responses. **Final types, lint, all 4,162 offline tests, production build and 40 HTTP/session tests pass.** Twenty-six new cases include real Moritani arrival alliances and production SQLite races, corruption rejection and private recovery. Review also repaired an older Cunning fixture's post-auction card-availability assumption; ten repeated runs passed 190 cases. Two three-seat browser rooms passed paid Fremen shipment/movement, mixed Emperor Homeworld shipment, desktop/phone controls and six private refreshes. The 03:46 UTC backed-up restart preserved all **3,612** opening games and restored twelve earlier private seats; final comparison preserves every opening version/hash, with two browser and thirty HTTP rooms added. Homeworld-return interpretation was asked of the user; that route, concealed transport, Betrayal and complete module/mode acceptance remain gated. Automation remains removed.
+
**Guild Nexus Cunning checkpoint, 10 September 2026:** [NEXUS_GUILD_CUNNING_RUNTIME.md](NEXUS_GUILD_CUNNING_RUNTIME.md) records a second paid native Guild shipment within the original combined turn, followed only by unused Hajr movement. Original shipment/movement history, typed Homeworld sources, cross/return routes, interception and later entry effects retain their rules. Private controls and all four bot profiles share availability; new binding shipment promises recognize the extra opportunity. Native Karama and paid BG conversion preserve the original source, and countering conversion restores it. Independent review fixed a later Moritani alliance freezing the remaining Hajr continuation; transport validation also rejects a same-territory cross-shipment. **Final types, lint, all 4,136 offline tests, production build and 40 HTTP/session tests pass.** Thirty-seven new cases include genuine nested interruptions, pending-frame corruption and competing production SQLite requests. Two three-seat browser rooms passed second shipment/Hajr and cancellation, desktop/phone controls and six private refreshes. A raw internal event notice found visually was corrected and verified by final rendering tests; earlier pending screenshots precede that text correction. All **3,580** opening room versions/hashes remain unchanged, with two browser and thirty HTTP rooms added. Guild Secret Ally/Betrayal, the physical Ornithopter combination, remaining native cancellation/arrival gaps and complete module/mode acceptance remain gated. Existing server reused, no restart due; automation remains removed.
**Richese Nexus Secret Ally checkpoint, 10 September 2026:** [NEXUS_RICHESE_RUNTIME.md](NEXUS_RICHESE_RUNTIME.md) records one-to-five actual reserve forces at the one-force price in the ordinary shipment pipeline. Native Guild/Fremen pricing, typed Homeworld reserves, native Ixian HMS entry, Guild prevention, BG accompaniment and real Terror entry retain their own rules. Private controls and all four bot levels share the quote; binding shipment promises recognize the affordable discounted source. Exact saved declarations and independent outcome markers protect pending and terminal history through corruption checks, competing SQL requests, Truthtrance and later turns. Review fixed the consumer's omitted native HMS destination. **Types, lint, all 4,099 offline tests, production build and 40 HTTP/session tests pass.** Thirty new cases and two three-seat browser rooms cover allowance/prevention, typed Emperor sources, desktop/phone controls and six private refreshes. The 02:48 UTC backed-up restart preserved all **3,548** opening games and restored twelve earlier private sessions. Final comparison leaves all opening versions/hashes unchanged, with two browser and thirty HTTP rooms added. Native Richese Cunning, Betrayal, foreign-Homeworld tariff integration and complete module/mode acceptance remain gated. The [Guild source audit](NEXUS_GUILD_RULES.md) prepares its distinct extra-shipment, borrowed-route and payment effects without claiming runtime completion. Automation remains removed.
diff --git a/docs/NEXUS_CARD_RUNTIME.md b/docs/NEXUS_CARD_RUNTIME.md
index 5d574c75..7a8d92e4 100644
--- a/docs/NEXUS_CARD_RUNTIME.md
+++ b/docs/NEXUS_CARD_RUNTIME.md
@@ -1,5 +1,7 @@
# Nexus cards: common lifecycle checkpoint
+**Later Guild Secret Ally runtime:** [NEXUS_GUILD_SECRET_ALLY_RUNTIME.md](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) records explicitly selected Guild prices, cross/return routes, typed Homeworld sources, paid Fremen reinforcement and committed shipment history. The card changes one shipment, without creating a native Guild or a real alliance. The Homeworld-return ruling and full module completion remain pending.
+
The later [Richese Secret Ally checkpoint](NEXUS_RICHESE_RUNTIME.md) adds an ordinary physical reserve shipment priced as one force; its other effects and complete module acceptance remain gated.
The later [Guild Cunning checkpoint](NEXUS_GUILD_CUNNING_RUNTIME.md) adds a separately recorded second shipment and remaining Hajr movement within the native Guild turn. Secret Ally, Betrayal and complete module acceptance remain unfinished.
diff --git a/docs/NEXUS_GUILD_RULES.md b/docs/NEXUS_GUILD_RULES.md
index d7e00a54..285b1b5e 100644
--- a/docs/NEXUS_GUILD_RULES.md
+++ b/docs/NEXUS_GUILD_RULES.md
@@ -1,5 +1,7 @@
# Spacing Guild Nexus: shipment source contract
+**Later runtime checkpoints:** [Guild Cunning](NEXUS_GUILD_CUNNING_RUNTIME.md) implements the extra-shipment continuation. [Guild Secret Ally](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) implements supported paid routes using the [focused source resolution](NEXUS_GUILD_SECRET_ALLY_RULES.md). The original audit below remains historical source evidence; its Homeworld-return question has now been sent to the user.
+
Source audit, 10 September 2026. This documents the three printed panels and their integration boundaries. It does not implement them or certify complete Guild, Homeworld, Nexus or expansion compliance. [Common Nexus lifecycle](NEXUS_CARD_RULES.md) and existing release gates continue to apply. No user question was sent during this audit.
## Primary evidence
diff --git a/docs/NEXUS_GUILD_SECRET_ALLY_RULES.md b/docs/NEXUS_GUILD_SECRET_ALLY_RULES.md
new file mode 100644
index 00000000..8c5e50b1
--- /dev/null
+++ b/docs/NEXUS_GUILD_SECRET_ALLY_RULES.md
@@ -0,0 +1,47 @@
+# Spacing Guild Nexus Secret Ally: route and price contract
+
+Source audit, 10 September 2026. This is a source-backed implementation contract, not certification of the whole card or either expansion module. The broader [Guild audit](NEXUS_GUILD_RULES.md) remains relevant to Cunning and Betrayal.
+
+## Evidence checked
+
+The original Guild component was visually reread in the [photograph of GF9's twelve Nexus cards](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani), using the existing local inspection copy `/tmp/dune-nexus-cards.jpg`. Its Secret Ally panel grants Guild shipment rates and expressly adds cross-planet shipment or return to the holder's reserves as their shipping action. It applies when Guild is absent. It does not promise all Guild advantages.
+
+Fresh searches retrieved official [base rules](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf), [November 2020 FAQ](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf), and [E3 rules](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf). Direct opening of the latter two URLs returned 403; their indexed publisher passages were checked against the existing E3 PDF/text. The [designers' page](https://futurepastimes.com/dune-board-game/) links the FAQ, but its short-link fetch failed. No Guild Secret Ally/Homeworld clarification was found in these sources. Tournament revisions and comments were not treated as official rulings.
+
+## Supported ordinary routes
+
+Use the holder's one ordinary shipping action in their own combined turn. Preserve their actual faction, typed counters, legal source group, stance and destination restrictions. The card does not add a second shipment or flexible Guild turn order. Rates apply to the whole selected physical group, rounding once upward. [GF9 base rules, pp19 and 23](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=19)
+
+| Route | Price for `n` physical forces | Boundary |
+| --- | --- | --- |
+| Off-planet reserves to an Arrakis stronghold | `ceil(n / 2)` | Ordinary legal destination, including native Ixian direct HMS permission only |
+| Off-planet reserves to another Arrakis territory | `n` | Ordinary storm and occupancy restrictions |
+| One Arrakis territory to another | Same destination tariff | Guild cross-shipment; neither source nor destination may be in storm |
+| One Arrakis territory to own reserves, Homeworlds off | `ceil(n / 2)` | Explicit card grant; no second halving |
+| Fremen southern reserves to Arrakis through the paid card route | Same destination tariff | Paid cross-shipment; distinct from free native reinforcement |
+
+The printed return grant exceeds the ordinary Guild alliance: the November FAQ says an ordinary Guild ally cannot return to reserves. Do not accidentally remove the Nexus permission by reusing only the normal alliance predicate. The Guild is absent, so payment goes to the bank; no native Guild collection or interception owner is created. [GF9 November FAQ, p9](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=9)
+
+## Fremen composition
+
+The official FAQ explicitly permits allied Guild cross-shipment from Fremen reserves, treating them as one on-planet territory outside the board. Composing that interpretation with the Nexus cross-shipment grant supports a paid southern-reserve route without Great Flat range. It keeps normal cross-shipment storm exclusion. The free native reinforcement route, including its own Advanced storm rule, remains separate. This is a composition of published rules, not a Nexus-specific FAQ answer. [GF9 November FAQ, p5](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=5)
+
+Native Homeworld custody can represent the same outgoing reserves without creating another reserve pool. A paid Fremen source remains on-planet for effects that distinguish off-planet shipping. Likewise, cross-shipping existing Arrakis forces does not trigger an off-planet arrival merely because its price is paid. Preserve the actual source classification for BG accompaniment and Heighliner. [GF9 E1, p5](https://www.gf9.com/Portals/0/Documents/GF9/IxianAndTleilaxuRulebook.pdf#page=5)
+
+## Homeworld prices and the unresolved return
+
+**Price and permission are separate.** E3 already permits world-to-world shipment by non-Guild factions, with a one-spice-per-force tariff and Guild half price. The card's Guild-rate clause therefore supports `ceil(n / 2)` for an independently legal world-to-world invasion. This price composition borrows no high-Junction advantage and grants no new destination. Preserve the source custody and own/ally-world restrictions. [GF9 E3, p10](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=10)
+
+**Arrakis to native reserves remains a material interpretation question when Homeworlds are enabled.** Reserves occupy native Homeworld discs; E3 expressly restricts Arrakis-to-Homeworld shipment to Guild, with a named Homeworld-advantage exception. The Nexus expressly grants reserve return. A card-specific exception is plausible, especially because it already exceeds normal alliance rights. Conversely, E3's specific Homeworld restriction can be read as limiting that grant. Its Nexus FAQ resolves neither reading. [GF9 E3, pp9–11 and 16](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=9)
+
+The concrete question is whether Secret Ally's reserve-return permission overrides that restriction **for the holder's own native reserves**. An affirmative answer does not grant Arrakis-to-arbitrary-foreign-Homeworld transport. Until resolved, expose this particular return as unfinished; ordinary Arrakis routes and independently legal world-to-world discounted shipment need not be blocked.
+
+## Implementation boundaries
+
+Consume the actual held Nexus atomically with a validated paid declaration, preserving an original route, source, typed group, destination and price receipt. A rejected request must not spend it. Later continuations must match the original declaration rather than infer it from a current hand or reset a usage flag. Private offers reveal no other holder's Nexus identity.
+
+Reuse `guildShipmentCost` for paid cross/return tariffs. Do not price the paid Fremen route through `reserveShipmentCost`, whose native Fremen branch intentionally returns zero. World-to-world pricing needs an explicit quoted rate modifier after route validation; changing the player's faction to Guild would also change custody and permissions. The native `quoteGuildHomeworldShipment` explicitly enforces Guild identity and ordinary Guild counters, so it is not a safe borrowed-return adapter without a separately settled contract.
+
+Generic Karama cannot target an absent native Guild player. That does not establish universal immunity for every borrowed effect. Preserve supported arrival reactions and existing independent Karama payment routing; do not stack two Guild half-rate modifiers into quarter price. Richese concealed No-Field price/transport composition remains its separate documented boundary, without blocking actual physical groups unnecessarily.
+
+Required runtime evidence includes odd/even prices, all supported route types, typed custody, Fremen free-versus-paid choices and storm behavior, native Ixian HMS access, current-source rejection, actual entry continuations, replay/JSON recovery, private projections and concurrent persistence. This audit changed documentation only.
diff --git a/docs/NEXUS_GUILD_SECRET_ALLY_RUNTIME.md b/docs/NEXUS_GUILD_SECRET_ALLY_RUNTIME.md
new file mode 100644
index 00000000..36898156
--- /dev/null
+++ b/docs/NEXUS_GUILD_SECRET_ALLY_RUNTIME.md
@@ -0,0 +1,53 @@
+# Guild Nexus Secret Ally runtime
+
+The [source contract](NEXUS_GUILD_SECRET_ALLY_RULES.md) distinguishes printed Guild shipping rates and routes from a real alliance or native Guild faction powers. This checkpoint implements the absent-Guild holder's explicit use for one ordinary shipment. It does not certify complete Guild, Nexus, Homeworld or expansion play.
+
+## Authoritative shipment and custody
+
+An unallied holder chooses the card for an unused Shipment and Movement shipment. Merely holding it does not change prices or legal destinations. An ordinary reserve shipment costs the Guild tariff: round half a spice per physical force upward for a stronghold, and one spice per force elsewhere. Native Homeworld reserves retain their exact ordinary/special source allocation. Native Ixian access to the mobile stronghold remains separate.
+
+The same grant permits one-territory cross-shipment or a return to abstract reserves. Actual multi-sector ordinary and special counters are selected and transferred; a destination cannot be the origin territory. The action uses the normal shipment opportunity and leaves the holder's ordinary movement available. The grant conveys neither flexible Guild turn order nor collection of another player's shipping payment.
+
+Fremen can retain their ordinary free reinforcement option. Choosing the Nexus instead pays Guild rates and allows destinations beyond the free Great Flat range, with the paid route's storm exclusion. The existing reserve withdrawal pipeline preserves typed Homeworld custody. These on-planet southern reinforcements do not become off-planet shipping for advisor accompaniment or Heighliner income.
+
+For an independently legal world-to-world shipment, the Nexus changes only the price to half a spice per force, rounded upward. Actual foreign/native source custody, special counters, own-world return restriction and ally-world prohibition remain authoritative. The card does not grant arbitrary Arrakis-to-foreign-Homeworld transport or high-Junction sponsorship.
+
+The material interaction between the card's return to reserves and the Homeworld native-Guild route restriction has been asked of the user. Until resolved, returning to Homeworld reserves with this grant is unavailable. Concealed No-Field transport retains its existing separate implementation boundary.
+
+## Payment, arrival and saved history
+
+Guild is absent from the seated faction roster, so the card does not invent a native Guild interception, discount cancellation or income recipient. A valid use immediately discards the physical Nexus and commits the shipment and bank payment. Other factions' genuine arrival decisions still occur normally. A later alliance formed by a valid entry effect does not invalidate the already completed shipment.
+
+The [pure receipt](../game/nexus-guild-secret-ally.ts) records the absent-Guild roster, original owner and turn. The engine adds the exact committed reserve, cross or Homeworld declaration and a separate latest-outcome marker. These consistency checks preserve historical evidence without freezing later movement, resources, alliances or card custody. They are not cryptographic authentication of arbitrary replacement saves. A Secret Ally shipment cannot retain a fabricated native Guild interception.
+
+A binding Truthtrance shipment answer can use this explicitly selected discounted source. The completion search preserves the actual typed withdrawal and returns the matching card-use action only to the entitled respondent. Ordinary shipment or movement cannot evade an accepted promise.
+
+## Verification and remaining work
+
+Focused verification passes **26 new cases**: six pure receipt/tariff cases, seven authoritative engine cases, four production SQLite recovery cases, and nine controls/all-profile bot cases. Actual paths include typed Emperor cross/return and world-to-world shipment, Fremen paid versus free reinforcement, native Ixian HMS access, an affordable-only-through-Nexus Truthtrance witness, and real Moritani placement, later arrival alliance and ordinary movement. SQLite checks cover competing shipment requests, lost acknowledgment, exact source/price corruption, private views, unrelated-room preservation and reconnecting before an actual alliance reply. The production build also passes; full repository and live HTTP/browser evidence are recorded separately below. The remaining Homeworld-return interpretation, concealed transport, Guild Betrayal, other native cancellation interactions and complete expansion games retain their release gates.
+
+## Player controls and bot evidence
+
+The [source-aware options](../game/nexus-guild-secret-ally-options.ts) require the owned current offer, held Guild Nexus, absent native Guild and ordinary unused shipment. The main shipment selector is explicitly unchecked by default; a separate checkbox in the [Homeworld panel](../components/homeworld-shipment.tsx) applies the same card only to that independently legal route. Prices describe the selected source. Fremen retain their free native choice and see why the selected paid route permits destinations beyond its range while excluding storm. A selected card does not silently attach itself to an ordinary movement.
+
+The [transport preview](../game/transport-quote.ts) keeps physical groups, elite allocations, same-territory exclusion and native permission checks. It adds the exact card permission for cross-shipment and abstract reserve return, with explicit guidance for unsupported Homeworld return and paid Fremen reserve entry through the normal reserve selector. The [Homeworld quote](../game/homeworld-shipment-options.ts) validates source and destination custody before applying the chosen tariff. Native Guild Homeworld transport remains its own route.
+
+All four bot profiles generate Guild-priced reserve groups before filtering by ordinary affordability, consider paid Fremen destinations beyond the free range and use the explicit source for eligible cross/return candidates. Ordinary free or equally priced routes preserve the card. Homeworld candidates use the discounted budget only with a current legal offer. The shared payment filter recognizes Guild and Richese sources separately; existing shipment-promise filtering consumes the physical action and the server's current source-specific completion witness. This is legal-policy coverage, without additional AI calibration claims.
+
+Nine new consumer tests passed: six [controls cases](../tests/nexus-guild-secret-ally-controls.test.ts) and three [all-profile bot cases](../tests/nexus-guild-secret-ally-bots.test.ts). They cover Basic/Advanced explicit selection, odd/even prices, Fremen free versus paid and storm behavior, typed cross/return, combined Emperor Homeworld custody, native Ixian HMS access, private/stale/blocked source fences and affordability. Sixty-eight existing shipment, Homeworld, Richese, promise and reference regressions passed in a separate compatibility run. Owned-file lint passed. The cross-shipment tests document their conserved pre-declaration Arrakis staging; module setup, card ownership and actual submitted shipment effects use the engine.
+
+## Browser checkpoint
+
+Two fresh three-human rooms used final session IDs before genuine fixture initialization, then an exact lobby state/version compare-and-swap. No existing room was staged. In `SE4XQL2Y`, the Fremen player selected the paid card route for five forces to Arrakeen at three spice, then moved those five normally to Hagga Basin. The final version is 4, with 17 spice and 15 reserves. In `WBW3Q5B4`, the Emperor selected three ordinary Kaitain forces and two Salusa Sardaukar for a Guild-priced invasion of Giedi Prime. Five typed forces arrived at three spice; version 3 retains 15 native reserves, three reserve Sardaukar and zero spice, with ordinary movement unused.
+
+Desktop 1440px and phone 390px screenshots were captured and visually inspected. Both checkbox defaults, selected prices and physical source inputs were exercised through the real browser controls. The final Homeworld explanation displays the selected Guild tariff. No horizontal overflow or page errors occurred. All six private sessions were refreshed and restored with unchanged completed versions, no hidden opponent hand/traitor/spice disclosure and no internal shipment/history fields in the player view.
+
+The read-only `/tmp/dune-guild-secret-restore.cjs` verifies the six sessions; `/tmp/dune-guild-secret-preserve.cjs` compares the opening room baseline. All 3,612 original room state hashes and versions remained unchanged. Screenshots are `/tmp/dune-guild-secret-fremen-desktop.png`, `/tmp/dune-guild-secret-fremen-phone.png`, `/tmp/dune-guild-secret-emperor-desktop.png`, `/tmp/dune-guild-secret-emperor-phone.png` and `/tmp/dune-guild-secret-emperor-phone-sources.png`. Browser cross/abstract-return interactions were not separately exercised in these two rooms; their actual actions are covered by engine and consumer regressions. Full-check, build and HTTP totals are recorded by the final project checkpoint.
+
+## Integration verification
+
+The first broad check found one older Guild Cunning SQL fixture failure: a real intervening auction could distribute all Karama copies before an unnecessary acquisition from the draw pile. The fixture now removes that acquisition, reserves one existing physical Hajr before the intervening phases, and reuses an already-held matching card. It does not duplicate a card or take one from an opposing hand. Ten independent repeated runs passed the eleven Cunning engine and eight SQL cases each, 190 case executions in total.
+
+The live HTTP/session suite passes **40/40** cases. The controlled **03:46 UTC** restart backed up and preserved all **3,612** opening saved-game versions and state hashes. Twelve earlier private Guild Cunning/Richese seats restored without errors. After the two new browser rooms and thirty HTTP rooms, all opening games remain unchanged and the database contains **3,644** rooms. The current server remains running and no recurring automation was created.
+
+Final `npm run check` passes types, lint and **4,162/4,162 offline tests** with no skipped cases after both the fixture correction and selected-tariff display fix. The final production build also passes. These checks establish this recorded subsystem scope, not complete expansion or rules-mode acceptance.
diff --git a/game/bots.ts b/game/bots.ts
index ef338a3a..33203eff 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -53,6 +53,7 @@ import { ownedTech } from './tech-tokens';
import type { StrongholdProgress } from './victory-progress';
import { canUseAsKarama } from './karama';
import { reserveShipmentCost } from './shipment-price';
+import { nexusGuildSecretAllyAction, nexusGuildSecretAllyCanAct, nexusGuildSecretAllyQuote } from './nexus-guild-secret-ally-options';
import { nexusRicheseAction, nexusRicheseQuote } from './nexus-richese-options';
import { liveShipmentPromises, matchesShipment } from './shipment-promises';
import {
@@ -2835,6 +2836,7 @@ function policyActions(g: GameView): Action[] {
g.players.some((p) => p.faction === 'guild' && p.id === me.ally) ||
g.karamaShipping?.player === me.id;
const shipmentBudget = (me.spice ?? 0) + g.aid.available;
+ const guildSecret = nexusGuildSecretAllyCanAct(g);
const noField = g.richeseNoField;
const ownNoField = noField?.owner === me.id ? noField.private : null;
const marker = ownNoField?.deployed;
@@ -2918,6 +2920,23 @@ function policyActions(g: GameView): Action[] {
});
}
}
+ // Generate paid Guild-source candidates before applying ordinary prices or Fremen range.
+ if (shipmentAvailable && guildSecret && me.reserves > 0)
+ for (const to of targets.filter(to => botEntryAllowed(g, me, to.t, to.s, 'guildShip')).slice(0, 24)) {
+ if (marker?.location.territory === to.t) continue;
+ const desired = Math.min(me.reserves, Math.max([3, 4, 5, 6][level], to.enemy + 2));
+ for (const amount of new Set([desired, Math.min(desired, shipmentBudget * (territory(to.t).type === 'stronghold' ? 2 : 1))])) {
+ const quote = nexusGuildSecretAllyQuote(g, to.t, amount);
+ if (!quote || quote.cost > shipmentBudget ||
+ (me.faction === 'fremen' && fremenReserveEntry(to.t)) ||
+ (me.faction !== 'fremen' && quote.cost >= reserveShipmentCost({faction: me.faction, halfRate}, territory(to.t).type, amount))) continue;
+ const candidate = nexusGuildSecretAllyAction(g, g.nexusGuildSecretAlly!.event, {
+ type: 'ship', territory: to.t, sector: to.s, amount,
+ elite: level > 0 ? Math.min(amount, me.elites?.reserves ?? 0) : Math.max(0, amount - (me.reserves - (me.elites?.reserves ?? 0))),
+ });
+ if (candidate) actions.push(candidate);
+ }
+ }
if (shipmentAvailable && me.reserves > 0)
for (const to of destinations(g, g.advanced && me.faction === 'fremen')
.filter(
@@ -2964,6 +2983,7 @@ function policyActions(g: GameView): Action[] {
}
}
const guildTransport =
+ guildSecret ||
me.faction === 'guild' ||
g.players.some((p) => p.faction === 'guild' && p.id === me.ally);
if (shipmentAvailable && guildTransport)
@@ -2972,7 +2992,7 @@ function policyActions(g: GameView): Action[] {
.slice(0, 16)) {
if (marker?.location.territory === to.t) continue;
if (
- me.faction === 'fremen' &&
+ !guildSecret && me.faction === 'fremen' &&
me.reserves > 0 &&
guildTransportCost(
to.t,
@@ -3007,6 +3027,7 @@ function policyActions(g: GameView): Action[] {
)
actions.push({
type: 'guildShip',
+ ...(guildSecret ? {nexus: g.nexusGuildSecretAlly!.event} : {}),
from,
territory: to.t,
sector: to.s,
@@ -3014,6 +3035,15 @@ function policyActions(g: GameView): Action[] {
});
}
}
+ if (shipmentAvailable && guildSecret && !g.homeworlds?.worlds?.length)
+ for (const [from, amount] of Object.entries(me.forces)) {
+ const source = splitLocation(from);
+ if (source.sector === g.storm || !g.players.some(p => p.id !== me.id && countAt(p, source.territory) > countAt(me, source.territory))) continue;
+ const quote = nexusGuildSecretAllyQuote(g, 'reserves', amount);
+ if (!quote || quote.cost > shipmentBudget) continue;
+ const candidate = nexusGuildSecretAllyAction(g, g.nexusGuildSecretAlly!.event, {type:'guildShip', from, amount, territory:'reserves'});
+ if (candidate) actions.push(candidate);
+ }
const movingSources = Object.entries(me.forces)
.map(([from, n]) => {
const origin = splitLocation(from).territory;
diff --git a/game/engine.ts b/game/engine.ts
index 609a5ed3..76718034 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -1,3 +1,4 @@
+import { createNexusGuildSecretAlly, validateNexusGuildSecretAlly, quoteNexusGuildSecretShipment, type NexusGuildSecretAllyReceipt } from './nexus-guild-secret-ally';
import { shipmentAvailable } from './shipment-opportunity';
import { createNexusGuildCunning, validateNexusGuildCunning, nexusGuildCunningMoves, type NexusGuildCunningReceipt } from './nexus-guild-cunning';
import { createNexusRichese, validateNexusRichese, quoteNexusRicheseShipment, type NexusRicheseReceipt } from './nexus-richese';
@@ -838,6 +839,7 @@ type KaramaUse =
| { kind: 'purchase' }
| { kind: 'auctionPayment' };
type PendingHomeworldShipment = HomeworldShipmentIntent & {
+ guildSecretEvent?: string;
guildNexusEvent?: string;
/** Absent in older world-to-world declarations. */
route?: 'arrakis';
@@ -850,6 +852,7 @@ type PendingHomeworldShipment = HomeworldShipmentIntent & {
pools: ReturnType['sources'] | ReturnType['boardSources'];
};
type PendingShipment = {
+ guildSecretEvent?: string;
guildNexusEvent?: string;
nexusEvent?: string;
homeworldSources?: NativeReserveSelections;
@@ -1291,6 +1294,8 @@ export type Game = {
nexusMoritaniHistory?: {receipt:NexusMoritaniReceipt;stage:'pending'|'complete'|'canceled';before:string;frame:string;signature:string}[];
nexusGuildCunningHistory?: {receipt:NexusGuildCunningReceipt;stage:'pending'|'secondShipment'|'extraMove'|'completed'|'canceled';parent:string;movesAfter:number;shipment?:{kind:'reserve'|'guild'|'homeworld';frame:string;stage:'pending'|'complete'|'stopped'}|{kind:'declined';stage:'declined'};signature:string}[];
nexusGuildCunningLast?: {event:string;stage:'pending'|'secondShipment'|'extraMove'|'completed'|'canceled'};
+ nexusGuildSecretHistory?: {receipt:NexusGuildSecretAllyReceipt;kind:'reserve'|'cross'|'homeworld';frame:string;signature:string}[];
+ nexusGuildSecretLast?: {event:string};
nexusRicheseHistory?: {receipt:NexusRicheseReceipt;stage:'pending'|'shipped'|'stopped';frame:string;halfRate:boolean;signature:string}[];
nexusRicheseLast?: {event:string;stage:'pending'|'shipped'|'stopped'};
nexusMoritaniLocations?: Record;
@@ -12117,6 +12122,7 @@ function marketGholaIntegrity(g: Game) {
nexusSardaukarIntegrity(g);
nexusChoamIntegrity(g);
nexusMoritaniIntegrity(g);
+ nexusGuildSecretIntegrity(g);
nexusRicheseIntegrity(g);
nexusGuildCunningIntegrity(g);
traitorDeclarationIntegrity(g);
@@ -12546,10 +12552,11 @@ function findShipmentCompletion(
)
continue;
for (const sector of territory(destination).sectors) {
- const offer = nexusRicheseOffer(g,p.id);
- for (const nexus of [undefined, ...(offer && !offer.blocked && amount <= offer.maxForces ? [offer.event] : [])]) {
+ const offer = nexusRicheseOffer(g,p.id), guildOffer = nexusGuildSecretAllyOffer(g,p.id);
+ for (const nexus of [undefined, ...(offer && !offer.blocked && amount <= offer.maxForces ? [offer.event] : []), ...(guildOffer && !guildOffer.blocked ? [guildOffer.event] : [])]) {
+ const guildSecretEvent = nexus && nexus === guildOffer?.event ? nexus : undefined;
const price = {faction:p.faction,halfRate:shipmentHalfRate(g,p)};
- const cost = nexus
+ const cost = guildSecretEvent ? nexusRule(() => quoteNexusGuildSecretShipment(territory(destination).type,amount)).cost : nexus
? nexusRule(() => quoteNexusRicheseShipment(price,territory(destination).type,amount)).cost
: reserveShipmentCost(price,territory(destination).type,amount);
try {
@@ -12570,7 +12577,7 @@ function findShipmentCompletion(
: null;
const nativeSources = sources ? { homeworldSources: sources } : {};
const shipment: PendingShipment = {
- ...(nexus ? {nexusEvent:nexus} : {}),
+ ...(guildSecretEvent ? {guildSecretEvent} : nexus ? {nexusEvent:nexus} : {}),
...nativeSources,
turn: g.turn,
player: p.id,
@@ -12583,7 +12590,7 @@ function findShipmentCompletion(
advisors: arrivalAsAdvisor(g, p, destination),
};
const trial = nexus ? structuredClone(g) : g;
- if (nexus) recordNexusRicheseShipment(trial,shipment);
+ if (nexus && !guildSecretEvent) recordNexusRicheseShipment(trial,shipment);
validatePhysicalShipment(trial,shipment);
return {
actions: [
@@ -15698,7 +15705,7 @@ function performJunctionTransport(g: Game, p: Player, action: Action) {
openTerritoryEntry(g, p, arrival.territory, arrival.sector, quote.amount, quote.elite, 'shipment');
}
}
-function homeworldShipmentQuote(g: Game, intent: HomeworldShipmentIntent & {route?: 'arrakis'}) {
+function homeworldShipmentQuote(g: Game, intent: HomeworldShipmentIntent & {route?: 'arrakis';guildSecretEvent?:string}) {
requireRule(g.homeworlds?.custody, 'Homeworld shipment requires saved physical custody.');
const context = {
...homeworldContext(g),
@@ -15713,10 +15720,15 @@ function homeworldShipmentQuote(g: Game, intent: HomeworldShipmentIntent & {rout
return {...quote, sources: quote.boardSources, sourceNames: [territory(quote.origin).name]};
}
const quote = homeworldRule(() => quoteHomeworldShipment(context, g.homeworlds!.custody!, order));
+ if (intent.guildSecretEvent) {
+ validateGuildSecretDeclaration(g,intent.player,intent.guildSecretEvent);
+ quote.cost = nexusRule(() => quoteNexusGuildSecretShipment('homeworld',quote.amount)).cost;
+ }
return {...quote, boardForces: undefined, boardEliteForces: undefined,
sourceNames: quote.sources.map((s) => combatLocationName(g, s.homeworld))};
}
function validateHomeworldShipment(g: Game, shipment: PendingHomeworldShipment) {
+ if (shipment.guildSecretEvent) requireRule(!shipment.route && !shipment.guildNexusEvent, 'Guild Secret Ally discounts independently legal world-to-world routes only.');
if (shipment.guildNexusEvent) validateGuildCunningShipment(g,shipment.player,shipment.guildNexusEvent,'homeworld',shipment);
const p = getPlayer(g, shipment.player);
requireRule(g.status === 'playing' && g.phase === 5 && g.active === p.id && shipmentAvailable(g,p) &&
@@ -15753,6 +15765,7 @@ function homeworldShipmentIntegrity(g: Game) {
}
function commitHomeworldShipment(g: Game, shipment: PendingHomeworldShipment) {
const quote = validateHomeworldShipment(g, shipment);
+ if (shipment.guildSecretEvent) recordGuildSecretShipment(g,shipment.player,shipment.guildSecretEvent,'homeworld',shipment);
if (shipment.guildNexusEvent) finishGuildCunningShipment(g,shipment.player,'homeworld',shipment);
const p = getPlayer(g, shipment.player);
payWithAlly(g, p, quote.cost, shipment.allyPayment);
@@ -15778,10 +15791,10 @@ function commitHomeworldShipment(g: Game, shipment: PendingHomeworldShipment) {
if (guild && income > 0)
g.response = guildPaymentResponse(g, guild.id, [income]);
finishShipmentPromises(g, p, null);
- log(g, `${p.name} shipped ${quote.amount} physical forces (${quote.elite} special) from ${quote.sourceNames.join(' and ')} to ${combatLocationName(g, shipment.destination)} for ${quote.cost} spice (${quote.cost - shipment.allyPayment} own, ${shipment.allyPayment} pledged). This uses their shipment; movement remains available.`,
- {faction: p.faction, name: 'Homeworld shipment'});
+ log(g, `${p.name} shipped ${quote.amount} physical forces (${quote.elite} special) from ${quote.sourceNames.join(' and ')} to ${combatLocationName(g, shipment.destination)} for ${quote.cost} spice (${quote.cost - shipment.allyPayment} own, ${shipment.allyPayment} pledged). This uses their shipment; movement remains available.${shipment.guildSecretEvent ? ' Guild Secret Ally is spent for this half-price shipment; the payment goes to the bank.' : ''}`,
+ {faction: shipment.guildSecretEvent ? 'guild' : p.faction, name: shipment.guildSecretEvent ? 'Secret Ally Homeworld shipment' : 'Homeworld shipment'});
}
-function declareHomeworldShipment(g: Game, p: Player, intent: HomeworldShipmentIntent & {route?: 'arrakis'}, allyPayment?: unknown) {
+function declareHomeworldShipment(g: Game, p: Player, intent: HomeworldShipmentIntent & {route?: 'arrakis';guildSecretEvent?:string}, allyPayment?: unknown) {
const quote = homeworldShipmentQuote(g, intent);
const shipment: PendingHomeworldShipment = {...intent, ...(currentGuildCunning(g,p.id) ? {guildNexusEvent:currentGuildCunning(g,p.id)!.receipt.event} : {}), sources: structuredClone(intent.sources),
event: homeworldShipmentEvent(g, p, intent.route), turn: g.turn, amount: quote.amount, elite: quote.elite,
@@ -15796,6 +15809,11 @@ function declareHomeworldShipment(g: Game, p: Player, intent: HomeworldShipmentI
} else commitHomeworldShipment(g, shipment);
}
function validatePhysicalShipment(g: Game, shipment: PendingShipment) {
+ if (shipment.guildSecretEvent) {
+ validateGuildSecretDeclaration(g,shipment.player,shipment.guildSecretEvent);
+ requireRule(!shipment.nexusEvent && !shipment.guildNexusEvent && !shipment.noField && !shipment.alliedNoField && !shipment.source,
+ 'Guild Secret Ally needs its own ordinary physical reserve shipment.');
+ }
if (shipment.guildNexusEvent) validateGuildCunningShipment(g,shipment.player,shipment.guildNexusEvent,'reserve',shipment);
if (shipment.nexusEvent !== undefined) currentNexusRicheseShipment(g,shipment);
requireRule(
@@ -15850,10 +15868,10 @@ function validatePhysicalShipment(g: Game, shipment: PendingShipment) {
p,
shipment.territory,
shipment.sector,
- g.advanced && p.faction === 'fremen',
+ !shipment.guildSecretEvent && g.advanced && p.faction === 'fremen',
shipment.advisors,
);
- if (p.faction === 'fremen')
+ if (p.faction === 'fremen' && !shipment.guildSecretEvent)
requireRule(
territory('the_great_flat').sectors.some((from) =>
territory(shipment.territory).sectors.some(
@@ -15867,7 +15885,7 @@ function validatePhysicalShipment(g: Game, shipment: PendingShipment) {
'Fremen reinforcements must arrive within two territories of the Great Flat.',
);
const price = {faction:p.faction,halfRate:shipmentHalfRate(g,p)};
- const cost = shipment.nexusEvent
+ const cost = shipment.guildSecretEvent ? nexusRule(() => quoteNexusGuildSecretShipment(territory(shipment.territory).type,n)).cost : shipment.nexusEvent
? nexusRule(() => quoteNexusRicheseShipment(price,territory(shipment.territory).type,n)).cost
: reserveShipmentCost(price,territory(shipment.territory).type,n);
requireRule(
@@ -15945,6 +15963,7 @@ function commitShipment(g: Game, shipment: PendingShipment) {
return;
}
validatePhysicalShipment(g, shipment);
+ if (shipment.guildSecretEvent) recordGuildSecretShipment(g,shipment.player,shipment.guildSecretEvent,'reserve',shipment);
finishNexusRicheseShipment(g,shipment,'shipped');
if (shipment.guildNexusEvent) finishGuildCunningShipment(g,shipment.player,'reserve',shipment);
finishShipmentPromises(
@@ -16059,10 +16078,10 @@ function commitShipment(g: Game, shipment: PendingShipment) {
? `${getPlayer(g, shipment.alliedNoField.owner).name} shipped ${p.name} with No-Field ${quoteAlliedTokenValue(g, shipment.alliedNoField)}, immediately placing ${n} physical forces (${elite} elite) in ${territory(to).name}, sector ${s}. ${shipment.alliedNoField.payer === 'both' ? 'Each ally paid 1 spice' : `${getPlayer(g, shipment.alliedNoField.payer).name} paid ${cost} spice`}.`
: shipment.noField
? `${p.name} shipped one concealed No-Field to ${territory(to).name}, sector ${s}, for ${cost} spice. It counts as one force; physical reserves remain unchanged until reveal.`
- : `${p.name} shipped ${n} forces to ${territory(to).name}, sector ${s}.${shipment.nexusEvent ? ` Richese Secret Ally priced the ${n} physical forces as one: ${cost} spice.` : ''}${homeworldOrigins ? ` Homeworld sources: ${homeworldOrigins}. Total shipment cost: ${cost} spice.` : ''}`,
+ : `${p.name} shipped ${n} forces to ${territory(to).name}, sector ${s}.${shipment.guildSecretEvent ? ` Guild Secret Ally is spent; Guild shipping prices cost ${cost} spice paid to the bank.` : ''}${shipment.nexusEvent ? ` Richese Secret Ally priced the ${n} physical forces as one: ${cost} spice.` : ''}${homeworldOrigins ? ` Homeworld sources: ${homeworldOrigins}. Total shipment cost: ${cost} spice.` : ''}`,
shipment.noField || shipment.alliedNoField
? { faction: 'richese', name: 'No-Field shipment' }
- : shipment.nexusEvent ? {faction:'richese',name:'Secret Ally shipment'} : undefined,
+ : shipment.guildSecretEvent ? {faction:'guild',name:'Secret Ally shipment'} : shipment.nexusEvent ? {faction:'richese',name:'Secret Ally shipment'} : undefined,
);
const bg = byFaction(g, 'beneGesserit');
const followup =
@@ -16300,6 +16319,70 @@ function nexusGuildCunningIntegrity(g: Game) {
} else requireRule(!g.pendingShipment?.guildNexusEvent && !g.pendingHomeworldShipment?.guildNexusEvent,
'Guild Cunning has a shipment without its original pending record.');
}
+function nexusGuildSecretAllyOffer(g: Game, owner: string) {
+ const p = g.players.find(p => p.id === owner);
+ if (!p || g.nexusCards?.cards?.hands[owner] !== 'guild' || byFaction(g,'guild')) return null;
+ let blocked: string | null = null;
+ if (p.ally) blocked = 'Guild Secret Ally requires an unallied holder.';
+ else if (g.status !== 'playing' || g.phase !== 5 || g.active !== owner || !shipmentAvailable(g,p))
+ blocked = 'Use Guild Secret Ally for your unused shipment before moving.';
+ else if (g.response || g.decision || g.truthtrance || g.phaseOpening || g.pendingKarama ||
+ g.pendingNullentropy || g.pendingTreacheryDiscard || g.nexusTraitorPending || g.nexusCards.phase?.stage === 'drawing')
+ blocked = 'Finish the current interaction before declaring this shipment.';
+ else if (g.nexusGuildSecretHistory?.some(r => r.receipt.owner === owner && r.receipt.turn === g.turn))
+ blocked = 'Guild Secret Ally has already been used this turn.';
+ return {event:JSON.stringify(['nexusGuildSecretAlly',g.turn,owner]),blocked};
+}
+function validateGuildSecretDeclaration(g: Game, owner: string, event: unknown) {
+ const offer = nexusGuildSecretAllyOffer(g,owner);
+ requireRule(offer && !offer.blocked && event === offer.event,
+ offer?.blocked ?? 'This Guild Secret Ally shipment is unavailable or stale.');
+ return offer.event;
+}
+function guildSecretSignature(record: NonNullable[number]) {
+ return JSON.stringify([record.receipt.signature,record.kind,record.frame]);
+}
+function recordGuildSecretShipment(g: Game, owner: string, event: string, kind: 'reserve'|'cross'|'homeworld', frame: object) {
+ validateGuildSecretDeclaration(g,owner,event);
+ const receipt = nexusRule(() => createNexusGuildSecretAlly(g,owner));
+ const record = {receipt,kind,frame:JSON.stringify(frame),signature:''};
+ record.signature = guildSecretSignature(record);
+ g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,owner,g.players));
+ (g.nexusGuildSecretHistory ??= []).push(record);
+ g.nexusGuildSecretLast = {event};
+}
+function nexusGuildSecretIntegrity(g: Game) {
+ const history = g.nexusGuildSecretHistory;
+ requireRule(!g.pendingShipment?.guildSecretEvent && !g.pendingHomeworldShipment?.guildSecretEvent,
+ 'Absent-Guild Secret Ally cannot retain a native Guild interception.');
+ if (history === undefined) {
+ requireRule(!g.nexusGuildSecretLast,'Guild Secret Ally has lost its original shipment history.'); return;
+ }
+ requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0,
+ 'Guild Secret Ally requires its original Nexus module and shipment history.');
+ const events = new Set();
+ for (const record of history) {
+ requireRule(record && Object.keys(record).sort().join(',') === 'frame,kind,receipt,signature' &&
+ ['reserve','cross','homeworld'].includes(record.kind) && typeof record.frame === 'string' &&
+ record.receipt && record.signature === guildSecretSignature(record) && !events.has(record.receipt.event),
+ 'Guild Secret Ally has a changed or duplicated shipment record.');
+ nexusRule(() => validateNexusGuildSecretAlly(g,record.receipt));
+ const frame = nexusRule(() => JSON.parse(record.frame)) as Record;
+ requireRule(frame && typeof frame === 'object' && frame.guildSecretEvent === record.receipt.event &&
+ frame.player === record.receipt.owner && frame.turn === record.receipt.turn &&
+ !frame.nexusEvent && !frame.guildNexusEvent && !frame.noField && !frame.alliedNoField && !frame.source,
+ 'Guild Secret Ally has changed its original shipment source.');
+ const destination = record.kind === 'homeworld' ? 'homeworld' :
+ record.kind === 'cross' && frame.to === 'reserves' ? 'reserves' : territory(String(record.kind === 'cross' ? frame.to : frame.territory)).type;
+ const quote = nexusRule(() => quoteNexusGuildSecretShipment(destination,frame.amount as number));
+ requireRule(frame.cost === quote.cost && frame.allyPayment === 0 && Number.isSafeInteger(frame.elite) &&
+ Number(frame.elite) >= 0 && Number(frame.elite) <= quote.physicalAmount,
+ 'Guild Secret Ally has changed its original payment or typed allocation.');
+ events.add(record.receipt.event);
+ }
+ requireRule(JSON.stringify(g.nexusGuildSecretLast) === JSON.stringify({event:history.at(-1)!.receipt.event}),
+ 'Guild Secret Ally has lost its latest shipment outcome.');
+}
function nexusRicheseOffer(g: Game, owner: string) {
const p = g.players.find(p => p.id === owner);
if (!p || g.nexusCards?.cards?.hands[owner] !== 'richese' || byFaction(g,'richese')) return null;
@@ -20126,15 +20209,16 @@ function applyActionInner(
return g;
}
if (t === 'homeworldShip' || t === 'guildHomeworldShip') {
- requireRule(action.nexus === undefined, 'Richese Secret Ally applies only to an ordinary Arrakis reserve shipment.');
+ const guildSecretEvent = action.nexus === undefined ? undefined : validateGuildSecretDeclaration(g,id,action.nexus);
+ requireRule(!guildSecretEvent || t === 'homeworldShip', 'Guild Secret Ally does not grant arbitrary Arrakis-to-Homeworld routes.');
const route = t === 'guildHomeworldShip' ? 'arrakis' as const : undefined;
const blocked = route ? guildHomeworldShipmentBlock(g, p) : homeworldShipmentBlock(g, p);
requireRule(!blocked, blocked ?? 'Homeworld shipment is unavailable.');
- requireRule(Object.keys(action).every((key) => ['type', 'event', 'destination', 'sources', 'allyPayment'].includes(key)),
+ requireRule(Object.keys(action).every((key) => ['type', 'event', 'destination', 'sources', 'allyPayment', ...(guildSecretEvent ? ['nexus'] : [])].includes(key)),
'Choose explicit typed source groups and a Homeworld destination without concealed tokens.');
requireRule(action.event === homeworldShipmentEvent(g, p, route), 'This Homeworld source selection is stale.');
declareHomeworldShipment(g, p, {player: id, destination: stringField(action.destination),
- sources: action.sources as HomeworldShipmentIntent['sources'], ...(route ? {route} : {})}, action.allyPayment);
+ sources: action.sources as HomeworldShipmentIntent['sources'], ...(route ? {route} : {}), ...(guildSecretEvent ? {guildSecretEvent} : {})}, action.allyPayment);
return g;
}
if (t === 'ship') {
@@ -20143,7 +20227,12 @@ function applyActionInner(
'You may ship once, before your movement.',
);
let nexusEvent: string | undefined;
- if (action.nexus !== undefined) {
+ let guildSecretEvent: string | undefined;
+ if (action.nexus !== undefined && action.nexus === nexusGuildSecretAllyOffer(g,id)?.event) {
+ guildSecretEvent = validateGuildSecretDeclaration(g,id,action.nexus);
+ requireRule(Object.keys(action).every(k => ['type','territory','sector','amount','elite','homeworldSources','allyPayment','nexus'].includes(k)),
+ 'Guild Secret Ally needs an ordinary physical reserve shipment.');
+ } else if (action.nexus !== undefined) {
const offer = nexusRicheseOffer(g,id);
requireRule(offer && !offer.blocked && action.nexus === offer.event,
offer?.blocked ?? 'This Richese Secret Ally shipment is unavailable or stale.');
@@ -20162,7 +20251,7 @@ function applyActionInner(
to !== MOBILE_STRONGHOLD || p.faction === 'ixians',
'Only Ixians may ship directly into the mobile stronghold.',
);
- allowedEntry(g, p, to, s, g.advanced && p.faction === 'fremen', advisors);
+ allowedEntry(g, p, to, s, !guildSecretEvent && g.advanced && p.faction === 'fremen', advisors);
let noField: PendingShipment['noField'];
if (action.noField !== undefined) {
requireRule(
@@ -20198,10 +20287,10 @@ function applyActionInner(
? 0
: eliteChoice(n, p.reserves, p.elites?.reserves ?? 0, action.elite);
const price = {faction:p.faction,halfRate:shipmentHalfRate(g,p)};
- const cost = nexusEvent
+ const cost = guildSecretEvent ? nexusRule(() => quoteNexusGuildSecretShipment(territory(to).type,n)).cost : nexusEvent
? nexusRule(() => quoteNexusRicheseShipment(price,territory(to).type,n)).cost
: reserveShipmentCost(price,territory(to).type,n);
- if (p.faction === 'fremen') {
+ if (p.faction === 'fremen' && !guildSecretEvent) {
requireRule(
territory('the_great_flat').sectors.some((fs) =>
territory(to).sectors.some(
@@ -20220,6 +20309,7 @@ function applyActionInner(
const shipment: PendingShipment = {
...(currentGuildCunning(g,id) ? {guildNexusEvent:currentGuildCunning(g,id)!.receipt.event} : {}),
...(nexusEvent ? {nexusEvent} : {}),
+ ...(guildSecretEvent ? {guildSecretEvent} : {}),
turn: g.turn,
player: id,
territory: to,
@@ -20251,7 +20341,9 @@ function applyActionInner(
return g;
}
if (t === 'guildShip') {
- requireRule(action.nexus === undefined, 'Choose ordinary reserve shipment to use Richese Secret Ally.');
+ const guildSecretEvent = action.nexus === undefined ? undefined : validateGuildSecretDeclaration(g,id,action.nexus);
+ if (guildSecretEvent) requireRule(Object.keys(action).every(k => ['type','from','forces','eliteForces','amount','elite','territory','sector','allyPayment','nexus'].includes(k)),
+ 'Guild Secret Ally cross-shipment needs explicit physical forces.');
const homeworldTarget = action.territory ?? 'reserves';
if (g.homeworlds?.custody && p.faction === 'guild' &&
(homeworldTarget === 'reserves' || (typeof homeworldTarget === 'string' && homeworldTarget.startsWith('homeworld:')))) {
@@ -20282,10 +20374,11 @@ function applyActionInner(
g.phase === 5 &&
g.active === id &&
shipmentAvailable(g,p) &&
- (p.faction === 'guild' || byFaction(g, 'guild')?.id === p.ally),
+ (p.faction === 'guild' || byFaction(g, 'guild')?.id === p.ally || !!guildSecretEvent),
'Guild shipment is not available.',
);
const fromReserves = action.from === 'reserves';
+ requireRule(!guildSecretEvent || !fromReserves, 'Use the reserve shipment selector for paid Guild Secret Ally reinforcements.');
requireRule(
!fromReserves || p.faction === 'fremen',
'Only allied Fremen may cross-ship from their southern reserves.',
@@ -20318,9 +20411,11 @@ function applyActionInner(
const sector = integer(action.sector ?? 0, 0, 18);
requireRule(fromReserves || to === 'reserves' || to !== origin, 'Cross-shipment requires a different destination territory.');
requireRule(
- to !== 'reserves' || p.faction === 'guild',
+ to !== 'reserves' || p.faction === 'guild' || !!guildSecretEvent,
'Only the Guild may ship forces back to reserves.',
);
+ requireRule(!guildSecretEvent || to !== 'reserves' || !g.homeworlds?.custody,
+ 'Guild Secret Ally return to Homeworld reserves awaits its route ruling.');
const advisors = to !== 'reserves' && arrivalAsAdvisor(g, p, to);
const sourceLock = p.advisors?.[origin]?.lockedTurn;
requireRule(
@@ -20346,6 +20441,7 @@ function applyActionInner(
const allyPayment = contribution(g, p, cost, action.allyPayment);
checkShipmentIncomeRounding(g, p, cost, allyPayment);
const cunningFrame = {turn:g.turn,player:id,origin,group,eliteGroup,to,sector,amount:n,elite,cost,allyPayment,advisors};
+ if (guildSecretEvent) recordGuildSecretShipment(g,id,guildSecretEvent,'cross',{...cunningFrame,guildSecretEvent});
bindGuildCunningShipment(g,p,'guild',cunningFrame);
payWithAlly(g, p, cost, allyPayment);
const guild = byFaction(g, 'guild');
@@ -20385,7 +20481,8 @@ function applyActionInner(
finishGuildCunningShipment(g,id,'guild',cunningFrame);
p.shipped = true;
g.karamaShipping = null;
- log(g, `${p.name} used Guild transport for ${n} forces.`);
+ log(g, `${p.name} used Guild transport for ${n} forces.${guildSecretEvent ? ` Guild Secret Ally is spent; ${cost} spice was paid to the bank. Their ordinary movement remains available.` : ''}`,
+ guildSecretEvent ? {faction:'guild',name:'Secret Ally transport'} : undefined);
if (to !== 'reserves') {
intrusion(g, p, to);
if (origin !== to)
@@ -21328,6 +21425,7 @@ export function viewGame(state: Game, id: string) {
nexusCards: projectedNexusCards(g, id),
nexusMoritani: nexusMoritaniOffer(g,id),
nexusRichese: nexusRicheseOffer(g,id),
+ nexusGuildSecretAlly: nexusGuildSecretAllyOffer(g,id),
nexusGuildCunning: projectedNexusGuildCunning(g,id),
nexusTraitors: projectedNexusTraitors(g, id),
nexusTleilaxu: projectedNexusTleilaxu(g, id),
diff --git a/game/homeworld-payment-options.ts b/game/homeworld-payment-options.ts
index 481b33d0..b4114207 100644
--- a/game/homeworld-payment-options.ts
+++ b/game/homeworld-payment-options.ts
@@ -1,3 +1,4 @@
+import { nexusGuildSecretAllyQuote } from './nexus-guild-secret-ally-options';
import type { Action, GameView } from './engine';
import { quoteGuildPaymentRounding } from './homeworld-payment-income';
import { reserveShipmentCost, guildShipmentCost } from './shipment-price';
@@ -18,12 +19,13 @@ export function homeworldShipmentPaymentBlock(g: GameView, cost: number, allyPay
export function botHomeworldShipmentPaymentAllowed(g: GameView, action: Action): boolean {
if (!['ship', 'guildShip'].includes(action.type)) return true;
- if (!g.homeworlds?.worlds?.some(world => world.card === 'junction' && world.side === 'low')) return true;
const me = g.players.find(p => p.id === g.me)!;
const to = String(action.territory);
- const amount = action.noField ? 1 : Number(action.amount);
- const nexusQuote = action.nexus === undefined ? null : nexusRicheseQuote(g, to, amount);
- if (action.nexus !== undefined && (!nexusQuote || action.nexus !== g.nexusRichese?.event || action.type !== 'ship' || action.noField !== undefined)) return false;
+ const amount = action.noField ? 1 : action.forces ? Object.values(action.forces as Record).reduce((sum, n) => sum + n, 0) : Number(action.amount);
+ const guildSource = action.nexus !== undefined && action.nexus === g.nexusGuildSecretAlly?.event;
+ const nexusQuote = action.nexus === undefined ? null : guildSource ? nexusGuildSecretAllyQuote(g, to, amount) : nexusRicheseQuote(g, to, amount);
+ if (action.nexus !== undefined && (!nexusQuote || (!guildSource && (action.nexus !== g.nexusRichese?.event || action.type !== 'ship')) || action.noField !== undefined)) return false;
+ if (!g.homeworlds?.worlds?.some(world => world.card === 'junction' && world.side === 'low')) return true;
const cost = nexusQuote ? nexusQuote.cost : action.type === 'ship'
? reserveShipmentCost({ faction: me.faction, halfRate: me.faction === 'guild' ||
g.players.some(p => p.faction === 'guild' && p.id === me.ally) || g.karamaShipping?.player === me.id }, territory(to).type, amount)
diff --git a/game/homeworld-shipment-options.ts b/game/homeworld-shipment-options.ts
index 898c61df..81ab259c 100644
--- a/game/homeworld-shipment-options.ts
+++ b/game/homeworld-shipment-options.ts
@@ -1,3 +1,7 @@
+import {
+ nexusGuildSecretAllyCanAct,
+ nexusGuildSecretAllyQuote,
+} from './nexus-guild-secret-ally-options';
import { nexusGuildShipmentAvailable } from './nexus-guild-cunning-options';
import type { Action, GameView } from './engine';
import { HOMEWORLD_CARDS } from './homeworld-cards';
@@ -23,6 +27,7 @@ export function homeworldShipmentChoice(
destination: string,
sources: HomeworldShipmentSources,
allyPayment = 0,
+ nexus?: string,
) {
const result: {
blocked: string | null;
@@ -87,14 +92,26 @@ export function homeworldShipmentChoice(
destination,
sources,
});
- result.cost = quote.cost;
+ const nexusQuote =
+ nexus === undefined
+ ? null
+ : nexusGuildSecretAllyQuote(view, destination, quote.amount);
+ if (
+ nexus !== undefined &&
+ (!nexusQuote || nexus !== view.nexusGuildSecretAlly?.event)
+ ) {
+ result.blocked = 'Choose a current Guild Secret Ally shipment offer.';
+ return result;
+ }
+ const cost = nexusQuote?.cost ?? quote.cost;
+ result.cost = cost;
result.amount = quote.amount;
result.elite = quote.elite;
- result.ownPayment = quote.cost - allyPayment;
+ result.ownPayment = cost - allyPayment;
if (
!Number.isSafeInteger(allyPayment) ||
allyPayment < 0 ||
- allyPayment > quote.cost ||
+ allyPayment > cost ||
allyPayment > (me.ally ? view.aid.available : 0)
)
result.blocked =
@@ -109,6 +126,7 @@ export function homeworldShipmentChoice(
destination,
sources,
allyPayment,
+ ...(nexus === undefined ? {} : { nexus }),
};
} catch (error) {
if (!(error instanceof HomeworldCustodyError)) throw error;
@@ -135,6 +153,7 @@ export function homeworldShipmentActions(
!nexusGuildShipmentAvailable(view)
)
return [];
+ const useNexus = nexusGuildSecretAllyCanAct(view);
const difficulty = Math.max(0, Math.min(3, level));
const budget = Math.max(
0,
@@ -212,7 +231,7 @@ export function homeworldShipmentActions(
const amount = Math.min(
pools.reduce((sum, pool) => sum + pool.capacity, 0),
[3, 5, 7, 9][difficulty],
- budget * (me.faction === 'guild' ? 2 : 1),
+ budget * (me.faction === 'guild' || useNexus ? 2 : 1),
Math.max([1, 3, 4, 5][difficulty], required),
);
if (amount <= 0) continue;
@@ -245,12 +264,21 @@ export function homeworldShipmentActions(
target.strength + (difficulty > 0 ? 1 : 0)
)
continue;
- const initial = homeworldShipmentChoice(view, target.world.id, selection);
+ const nexus =
+ useNexus && amount > 1 ? view.nexusGuildSecretAlly!.event : undefined;
+ const initial = homeworldShipmentChoice(
+ view,
+ target.world.id,
+ selection,
+ 0,
+ nexus,
+ );
const quote = homeworldShipmentChoice(
view,
target.world.id,
selection,
Math.max(0, initial.cost - (me.spice ?? 0)),
+ nexus,
);
if (
quote.action &&
diff --git a/game/nexus-guild-secret-ally-options.ts b/game/nexus-guild-secret-ally-options.ts
new file mode 100644
index 00000000..0a2cffdc
--- /dev/null
+++ b/game/nexus-guild-secret-ally-options.ts
@@ -0,0 +1,104 @@
+import type { Action, GameView } from './engine';
+import { gameTerritories, MOBILE_STRONGHOLD } from './board';
+import { quoteNexusGuildSecretShipment } from './nexus-guild-secret-ally';
+
+/** Consume only the current actor's private offer and public faction roster. */
+export function nexusGuildSecretAllyCanAct(g: GameView): boolean {
+ const me = g.players.find((p) => p.id === g.me);
+ if (
+ !me ||
+ me.ally ||
+ g.nexusCards?.card !== 'guild' ||
+ g.players.some((p) => p.faction === 'guild')
+ )
+ return false;
+ const offer = g.nexusGuildSecretAlly;
+ return !!(
+ offer &&
+ !offer.blocked &&
+ offer.event === JSON.stringify(['nexusGuildSecretAlly', g.turn, g.me]) &&
+ g.status === 'playing' &&
+ g.phase === 5 &&
+ g.active === g.me &&
+ !me.shipped &&
+ !g.response &&
+ !g.decision &&
+ !g.truthtrance &&
+ !g.phaseOpening &&
+ !g.automaticContinuationPending &&
+ !g.nexusCards.waiting.length &&
+ !g.nexusTraitors?.pending
+ );
+}
+
+/** Price is independent of native faction; route permission is still separate. */
+export function nexusGuildSecretAllyQuote(
+ g: GameView,
+ destination: string,
+ amount: number,
+) {
+ if (
+ !nexusGuildSecretAllyCanAct(g) ||
+ !Number.isSafeInteger(amount) ||
+ amount < 1 ||
+ amount > 20
+ )
+ return null;
+ const me = g.players.find((p) => p.id === g.me)!;
+ if (destination === 'reserves') {
+ if (g.homeworlds?.worlds?.length) return null;
+ return quoteNexusGuildSecretShipment('reserves', amount);
+ }
+ const world = g.homeworlds?.worlds?.find((w) => w.id === destination);
+ if (world) {
+ if (world.native === me.id || world.native === me.ally) return null;
+ return quoteNexusGuildSecretShipment('homeworld', amount);
+ }
+ const to = gameTerritories(g).find((t) => t.id === destination);
+ if (!to || (destination === MOBILE_STRONGHOLD && me.faction !== 'ixians'))
+ return null;
+ return quoteNexusGuildSecretShipment(to.type, amount);
+}
+
+/** Add a selected source, never infer card use just from possession. */
+export function nexusGuildSecretAllyAction(
+ g: GameView,
+ event: string,
+ action: Action,
+): Action | null {
+ if (
+ !nexusGuildSecretAllyCanAct(g) ||
+ event !== g.nexusGuildSecretAlly!.event ||
+ !['ship', 'guildShip', 'homeworldShip'].includes(action.type) ||
+ action.nexus !== undefined ||
+ action.noField !== undefined ||
+ (action.type === 'guildShip' && action.from === 'reserves')
+ )
+ return null;
+ const amount =
+ action.type === 'homeworldShip'
+ ? Object.values(
+ (action.sources ?? {}) as Record<
+ string,
+ { normal: number; elite: number }
+ >,
+ ).reduce((n, p) => n + p.normal + p.elite, 0)
+ : action.forces
+ ? Object.values(action.forces as Record).reduce(
+ (n, p) => n + p,
+ 0,
+ )
+ : Number(action.amount);
+ const destination = String(
+ action.type === 'homeworldShip' ? action.destination : action.territory,
+ );
+ if (!nexusGuildSecretAllyQuote(g, destination, amount)) return null;
+ const me = g.players.find((p) => p.id === g.me)!;
+ if (
+ action.type === 'ship' &&
+ (amount > me.reserves ||
+ !gameTerritories(g).some((t) => t.id === destination))
+ )
+ return null;
+ return { ...action, nexus: event };
+}
diff --git a/game/nexus-guild-secret-ally.ts b/game/nexus-guild-secret-ally.ts
new file mode 100644
index 00000000..628a4c4e
--- /dev/null
+++ b/game/nexus-guild-secret-ally.ts
@@ -0,0 +1,120 @@
+import { FACTIONS, type FactionId } from './catalog';
+import { guildShipmentCost } from './shipment-price';
+
+export type NexusGuildSecretAllyContext = {
+ turn: number;
+ players: readonly { id: string; faction: FactionId }[];
+};
+export type NexusGuildSecretAllyReceipt = {
+ version: 1;
+ event: string;
+ owner: string;
+ turn: number;
+ phase: 5;
+ mode: 'secretAlly';
+ roster: { id: string; faction: FactionId }[];
+ signature: string;
+};
+function requireGuild(value: unknown, message: string): asserts value {
+ if (!value) throw new Error(message);
+}
+const faction = (value: unknown): value is FactionId =>
+ FACTIONS.some((candidate) => candidate.id === value);
+const identifier = (value: unknown): value is string =>
+ typeof value === 'string' &&
+ value.trim().length > 0 &&
+ !['__proto__', 'prototype', 'constructor'].includes(value);
+const plain = (value: unknown): value is Record =>
+ !!value &&
+ typeof value === 'object' &&
+ !Array.isArray(value) &&
+ [Object.prototype, null].includes(Object.getPrototypeOf(value));
+
+/** The caller validates route permission and actual physical custody. */
+export function quoteNexusGuildSecretShipment(destinationType: string, amount: number) {
+ requireGuild(['stronghold','sand','rock','polar','reserves','homeworld'].includes(destinationType) &&
+ Number.isSafeInteger(amount) && amount > 0 && amount <= 20,
+ 'Guild Secret Ally requires a valid destination and one to twenty physical forces.');
+ return {physicalAmount:amount,cost:guildShipmentCost(destinationType === 'homeworld' ? 'reserves' : destinationType,amount),source:'nexusGuildSecretAlly' as const};
+}
+function roster(context: NexusGuildSecretAllyContext) {
+ requireGuild(
+ context &&
+ Number.isSafeInteger(context.turn) &&
+ context.turn > 0 &&
+ Array.isArray(context.players) &&
+ context.players.length > 0 &&
+ context.players.every(
+ (p) => p && identifier(p.id) && faction(p.faction),
+ ) &&
+ new Set(context.players.map((p) => p.id)).size ===
+ context.players.length &&
+ new Set(context.players.map((p) => p.faction)).size ===
+ context.players.length &&
+ !context.players.some((p) => p.faction === 'guild'),
+ 'Guild Secret Ally needs a valid original faction roster with the native Guild absent.',
+ );
+ return context.players
+ .map(({ id, faction }) => ({ id, faction }))
+ .sort((a, b) => a.id.localeCompare(b.id));
+}
+function signature(receipt: NexusGuildSecretAllyReceipt) {
+ return JSON.stringify([
+ 'nexusGuildSecretAllyReceipt',
+ receipt.version,
+ receipt.event,
+ receipt.owner,
+ receipt.turn,
+ receipt.phase,
+ receipt.mode,
+ receipt.roster.map((p) => [p.id, p.faction]),
+ ]);
+}
+/** Historical evidence stays valid after later card use, alliances and turns.
+ * Signatures establish consistency, not cryptographic authentication. */
+export function validateNexusGuildSecretAlly(
+ context: NexusGuildSecretAllyContext,
+ receipt: NexusGuildSecretAllyReceipt,
+): void {
+ const seats = roster(context);
+ requireGuild(
+ plain(receipt) &&
+ Object.keys(receipt).sort().join(',') ===
+ 'event,mode,owner,phase,roster,signature,turn,version' &&
+ receipt.version === 1 &&
+ receipt.phase === 5 &&
+ receipt.mode === 'secretAlly' &&
+ Number.isSafeInteger(receipt.turn) &&
+ receipt.turn > 0 &&
+ receipt.turn <= context.turn &&
+ identifier(receipt.owner) &&
+ seats.some((p) => p.id === receipt.owner) &&
+ receipt.event ===
+ JSON.stringify(['nexusGuildSecretAlly', receipt.turn, receipt.owner]) &&
+ Array.isArray(receipt.roster) &&
+ receipt.roster.every(
+ (p) => plain(p) && Object.keys(p).sort().join(',') === 'faction,id',
+ ) &&
+ JSON.stringify(receipt.roster) === JSON.stringify(seats) &&
+ receipt.signature === signature(receipt),
+ 'Guild Secret Ally has lost its original owner, turn, phase or faction roster.',
+ );
+}
+export function createNexusGuildSecretAlly(
+ context: NexusGuildSecretAllyContext,
+ owner: string,
+): NexusGuildSecretAllyReceipt {
+ const receipt: NexusGuildSecretAllyReceipt = {
+ version: 1,
+ event: JSON.stringify(['nexusGuildSecretAlly', context.turn, owner]),
+ owner,
+ turn: context.turn,
+ phase: 5,
+ mode: 'secretAlly',
+ roster: roster(context),
+ signature: '',
+ };
+ receipt.signature = signature(receipt);
+ validateNexusGuildSecretAlly(context, receipt);
+ return receipt;
+}
diff --git a/game/reference.ts b/game/reference.ts
index 6f1e989f..d1855622 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -3340,7 +3340,7 @@ export const RULE_TOPICS: RuleTopic[] = [
searchText: NEXUS_CARD_REFERENCE.map(card => `${card.faction} ${card.betrayal} ${card.cunning} ${card.secretAlly}`).join(' '),
category: 'Advanced & expansions',
coverage: 'Partial',
- summary: 'A separate twelve-card deck rewards remaining unallied. Private draws, Atreides inspections, Harkonnen exchanges and native Tleilaxu, Ixian, Bene Gesserit, Emperor, five CHOAM Cunning effects Moritani Cunning supply placement and Richese Secret Ally reserve shipping and Guild Cunning’s second shipment are integrated. Other effects and complete module play remain in development.',
+ summary: 'A separate twelve-card deck rewards remaining unallied. Private draws, Atreides inspections, Harkonnen exchanges and native Tleilaxu, Ixian, Bene Gesserit, Emperor, five CHOAM Cunning effects Moritani Cunning supply placement and Richese Secret Ally reserve shipping and Guild Cunning’s second shipment and absent-Guild Secret Ally shipping are integrated. Other effects and complete module play remain in development.',
steps: [
'This optional module can be combined with Basic or Advanced rules independently of the factions and other expansion modules selected. Include one Nexus Card for each of the twelve factions.',
'At the end of the entire Spice Blow and Nexus phase, if a Nexus occurred and at least one alliance exists, each unallied player may draw one card or discard their held card and draw a replacement. You may decline. The two Advanced spice piles do not grant two closing draws.',
@@ -3369,12 +3369,13 @@ export const RULE_TOPICS: RuleTopic[] = [
'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.',
'When Richese is absent, its Secret Ally replaces your ordinary reserve shipment with up to five physical forces charged as one. Select the explicit shipment checkbox; forces, elite identity, destination restrictions, Homeworld reserve sources and normal movement remain unchanged. Fremen remain free within their normal reinforcement range; Guild and an active Karama rate retain their existing tariff and payment routing. This is not a concealed No-Field or an extra shipment.',
'Guild Cunning is declared when finishing your ordinary Shipment and Movement turn. It spends the Nexus and opens one Karama response. Allowance grants a second native shipment at normal Guild prices, including supported cross-shipment, return and Homeworld routes. Cancellation leaves the completed first turn intact. Only an unused Hajr move can follow the second shipment; no ordinary movement or third move is granted. The physical Ornithopter card combination remains unavailable.',
+ 'With Guild absent, its Secret Ally is an explicit optional source for one ordinary shipment at Guild prices. It supports physical reserve shipment, cross-shipment and return to abstract reserves, plus half-price independently legal world-to-world shipment. Fremen may retain free reinforcement or choose the paid card route beyond its normal range, outside the storm. Native faction, typed sources and normal movement stay unchanged. Homeworld reserve return awaits a ruling; arbitrary foreign-world transport and concealed No-Fields are not granted.',
'Cunning spends its Nexus card and uses the native Mentat placement opportunity. A Karama response precedes placement; cancellation leaves the token where it was and the Nexus spent. Later Terror entry still uses the existing one-token stack selection and supported effect rules; this does not implement Atomics, Extortion or complete Moritani games.',
'Betrayal reactions await a private response timing decision. Other factions’ Nexus effects and complete module games are unfinished, so public module starts remain disabled.',
],
related: ['ecaz-modules', 'spice-blow', 'alliance-funding'],
checklist: [
- { area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, revealed Face Dancer refresh, turn-wide Suboid strength, whole-territory advisor conversion, temporary Emperor Sardaukar and five source-aware CHOAM Cunning effects Moritani supply placement beyond ordinary strongholds and Richese Secret Ally physical reserve shipping priced as one. Borrowed Voice, Betrayal, Tleilaxu revival and remaining effects are unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-richese-options.ts', 'game/nexus-guild-cunning-options.ts', 'game/engine.ts'] },
+ { area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, revealed Face Dancer refresh, turn-wide Suboid strength, whole-territory advisor conversion, temporary Emperor Sardaukar and five source-aware CHOAM Cunning effects Moritani supply placement beyond ordinary strongholds and Richese Secret Ally physical reserve shipping priced as one. Borrowed Voice, Betrayal, Tleilaxu revival and remaining effects are unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-richese-options.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation and counted advisor-territory selection, temporary Sardaukar and CHOAM physical-card/effect selection with native Karama responses, plus an explicit Richese shipment source and physical-force price breakdown. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All profiles handle the integrated effects through private offers, authorized choices and projected forces. Advisor choices use public opposing fighter counts. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-richese-options.ts', 'game/nexus-guild-cunning-options.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and the integrated effect families retain explicit unresolved boundaries, including fresh advisors, storm and borrowed Voice.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md'] },
diff --git a/game/transport-quote.ts b/game/transport-quote.ts
index c7282b48..aa3bdc25 100644
--- a/game/transport-quote.ts
+++ b/game/transport-quote.ts
@@ -1,4 +1,5 @@
import { nexusGuildShipmentAvailable } from './nexus-guild-cunning-options';
+import { nexusGuildSecretAllyCanAct } from './nexus-guild-secret-ally-options';
import type { Action, GameView } from './engine';
import { splitLocation, validLocation } from './board';
import { botEntryAllowed, guildTransportCost } from './bot-mobility';
@@ -10,6 +11,13 @@ import { shipmentPaymentBounds } from './shipment-price';
export function guildTransportQuote(g: GameView, action: Action) {
const p = g.players.find((seat) => seat.id === g.me)!;
const unavailableReasons: string[] = [];
+ const nexus = action.nexus !== undefined;
+ const nexusAllowed =
+ nexus &&
+ nexusGuildSecretAllyCanAct(g) &&
+ action.nexus === g.nexusGuildSecretAlly?.event;
+ if (nexus && !nexusAllowed)
+ unavailableReasons.push('Choose a current Guild Secret Ally offer.');
const fromReserves = action.from === 'reserves';
let total = 0;
let origin: string | undefined;
@@ -51,6 +59,10 @@ export function guildTransportQuote(g: GameView, action: Action) {
'Guild transport of a concealed No-Field is unavailable. Reveal the marker first or use its movement action.',
);
if (fromReserves) {
+ if (nexus)
+ unavailableReasons.push(
+ 'Use the paid reserve shipment selector for Guild Secret Ally.',
+ );
if (p.faction !== 'fremen')
unavailableReasons.push(
'Only allied Fremen may cross-ship southern reserves.',
@@ -112,8 +124,12 @@ export function guildTransportQuote(g: GameView, action: Action) {
sector >= 0 &&
sector <= 18 &&
(to === 'reserves' || validLocation(to, sector));
- if (to === 'reserves' && p.faction !== 'guild')
+ if (to === 'reserves' && p.faction !== 'guild' && !nexusAllowed)
unavailableReasons.push('Only the Guild may return forces to reserves.');
+ if (to === 'reserves' && nexus && g.homeworlds?.worlds?.length)
+ unavailableReasons.push(
+ 'Guild Secret Ally return to native Homeworld reserves awaits a ruling.',
+ );
if (!validDestination)
unavailableReasons.push('Choose a sector belonging to the destination.');
else if (
@@ -124,12 +140,15 @@ export function guildTransportQuote(g: GameView, action: Action) {
'This destination is unavailable because of the storm, allied forces, stronghold capacity, or your advisor restrictions.',
);
if (!fromReserves && to !== 'reserves' && to === origin)
- unavailableReasons.push('Guild cross-shipment must enter another territory.');
+ unavailableReasons.push(
+ 'Guild cross-shipment must enter another territory.',
+ );
if (
g.phase !== 5 ||
g.active !== p.id ||
!nexusGuildShipmentAvailable(g) ||
!(
+ nexusAllowed ||
p.faction === 'guild' ||
g.players.some((seat) => seat.id === p.ally && seat.faction === 'guild')
)
diff --git a/tests/fixture-nexus-guild-cunning.ts b/tests/fixture-nexus-guild-cunning.ts
index f90011af..b94c44b3 100644
--- a/tests/fixture-nexus-guild-cunning.ts
+++ b/tests/fixture-nexus-guild-cunning.ts
@@ -80,7 +80,12 @@ export function nexusGuildCunningRequest(
nexus: viewGame(g, f.owner).nexusGuildCunning!.offer!.event,
};
}
-export const holdGuildCunningCard = holdNexusRicheseCard;
+export function holdGuildCunningCard(g: Game, owner: string, kind: string) {
+ const held = g.players
+ .find((p) => p.id === owner)!
+ .hand.find((c) => c.kind === kind || c.effect === kind);
+ return held ?? holdNexusRicheseCard(g, owner, kind);
+}
export function nexusGuildCunningInventory(g: Game) {
// The shared card census also checks a pre-Homeworld aggregate force total.
// Count foreign visitors in that detached census; validate actual typed
@@ -106,6 +111,10 @@ export function nexusGuildCunningAllianceFixture(
seatIds?: [string, string, string],
): NexusGuildCunningFixture {
const f = nexusGuildCunningFixture({ seatIds, opponentFaction: 'moritani' });
+ // Reserve this actual card before the intervening real auction can distribute
+ // all copies. The later scenario reuses this owned identity rather than
+ // assuming a needed card remains in the randomly shuffled draw pile.
+ holdGuildCunningCard(f.g, f.owner, 'hajr');
let g = f.g;
while (g.phase === 5) g = applyAction(g, g.active!, { type: 'endMovement' });
assert.equal(g.phase, 7);
diff --git a/tests/fixture-nexus-guild-secret-ally.ts b/tests/fixture-nexus-guild-secret-ally.ts
new file mode 100644
index 00000000..b78ed763
--- /dev/null
+++ b/tests/fixture-nexus-guild-secret-ally.ts
@@ -0,0 +1,137 @@
+import assert from 'node:assert/strict';
+import { applyAction, viewGame, type Action, type Game } from '../game/engine';
+import {
+ nexusRicheseFixture,
+ nexusRicheseInventory,
+ holdNexusRicheseCard,
+ type NexusRicheseFixtureOptions,
+} from './fixture-nexus-richese';
+import {
+ nexusReload,
+ nexusAllow,
+ orderNexusSpice,
+} from './fixture-nexus-cards';
+import { botActions } from '../game/bots';
+import { nexusTraitorInventory } from './fixture-nexus-traitors';
+import { homeworldGameIntegrity } from '../game/homeworld-game';
+
+export type NexusGuildSecretAllyFixtureOptions = Omit<
+ NexusRicheseFixtureOptions,
+ 'guild' | 'ownerFaction' | 'opponentFaction'
+> & {
+ ownerFaction?: Exclude;
+ opponentFaction?: Exclude<
+ NexusRicheseFixtureOptions['opponentFaction'],
+ 'guild'
+ >;
+};
+/** Genuine final native setup without Guild. Exchange physical Nexus identities
+ * before any declaration; no effect receipt or shipment allowance is staged. */
+export function nexusGuildSecretAllyFixture(
+ options: NexusGuildSecretAllyFixtureOptions = {},
+) {
+ const f = nexusRicheseFixture({
+ ...options,
+ opponentFaction:
+ options.opponentFaction ??
+ (options.ownerFaction === 'harkonnen' ? 'beneGesserit' : 'harkonnen'),
+ });
+ const cards = f.g.nexusCards!.cards!,
+ index = cards.deck.indexOf('guild');
+ assert.ok(index >= 0);
+ assert.equal(cards.hands[f.owner], 'richese');
+ cards.deck[index] = 'richese';
+ cards.hands[f.owner] = 'guild';
+ assert.equal(
+ f.g.players.some((p) => p.faction === 'guild'),
+ false,
+ );
+ nexusRicheseInventory(f.g);
+ return f;
+}
+export function nexusGuildSecretAllyRequest(
+ g: Game,
+ owner: string,
+ action: Action = {
+ type: 'ship',
+ territory: 'arrakeen',
+ sector: 10,
+ amount: 5,
+ },
+): Action {
+ return { ...action, nexus: viewGame(g, owner).nexusGuildSecretAlly!.event };
+}
+export function nexusGuildSecretAllyInventory(g: Game) {
+ const census = structuredClone(g);
+ for (const p of census.players)
+ p.reserves += Object.values(g.homeworlds?.custody?.visitors ?? {}).reduce(
+ (sum, visitors) =>
+ sum + (visitors[p.id]?.normal ?? 0) + (visitors[p.id]?.elite ?? 0),
+ 0,
+ );
+ nexusTraitorInventory(census);
+ if (g.homeworlds) homeworldGameIntegrity(g);
+}
+export const nexusGuildSecretAllyReload = nexusReload;
+export const holdGuildSecretAllyCard = holdNexusRicheseCard;
+
+export function nexusGuildSecretAllianceFixture(
+ seatIds?: [string, string, string],
+) {
+ const f = nexusGuildSecretAllyFixture({
+ seatIds,
+ opponentFaction: 'moritani',
+ });
+ let g = f.g;
+ while (g.phase === 5) g = applyAction(g, g.active!, { type: 'endMovement' });
+ assert.equal(g.phase, 7);
+ for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
+ assert.equal(g.decision?.kind, 'moritaniPlacement');
+ const token = g.moritaniTerror!.tokens.find((t) => t.kind === 'robbery')!;
+ g = nexusAllow(
+ applyAction(g, f.target, {
+ type: 'decision',
+ token: token.id,
+ territory: 'arrakeen',
+ }),
+ );
+ let ordered = false;
+ for (
+ let step = 0;
+ !(
+ g.turn === 2 &&
+ g.phase === 5 &&
+ !g.response &&
+ !g.decision &&
+ !g.phaseOpening
+ ) && step < 180;
+ step++
+ ) {
+ if (g.turn === 2 && g.phase === 1 && !ordered) {
+ orderNexusSpice(g, ['land', 'land']);
+ ordered = true;
+ }
+ let next: Game | undefined;
+ for (const p of g.players) {
+ const v = viewGame(g, p.id);
+ v.players.find((s) => s.id === p.id)!.bot = 'Easy';
+ const a = botActions(v)[0];
+ if (a) {
+ next = applyAction(g, p.id, a);
+ break;
+ }
+ }
+ assert.ok(
+ next,
+ `Secret Ally alliance progression stalled at ${g.turn}/${g.phase}/${g.decision?.kind}`,
+ );
+ g = next;
+ }
+ assert.equal(g.turn, 2);
+ assert.equal(g.phase, 5);
+ while (g.active !== f.owner)
+ g = applyAction(g, g.active!, { type: 'endMovement' });
+ assert.equal(g.nexusCards!.cards!.hands[f.owner], 'guild');
+ nexusGuildSecretAllyInventory(g);
+ return { ...f, g };
+}
diff --git a/tests/nexus-guild-cunning-recovery.test.ts b/tests/nexus-guild-cunning-recovery.test.ts
index 936fd95f..f8bfaec9 100644
--- a/tests/nexus-guild-cunning-recovery.test.ts
+++ b/tests/nexus-guild-cunning-recovery.test.ts
@@ -47,7 +47,8 @@ async function fixture(
advanced: selfStop,
homeworlds,
});
- if (alliance) f.karama = holdGuildCunningCard(f.g, f.target, 'karama').id;
+ // The genuine alliance scenario passes whichever actual responders remain;
+ // it does not need to acquire another Karama after its intervening auction.
const ownKarama = selfStop
? holdGuildCunningCard(f.g, f.owner, 'karama').id
: undefined;
diff --git a/tests/nexus-guild-secret-ally-bots.test.ts b/tests/nexus-guild-secret-ally-bots.test.ts
new file mode 100644
index 00000000..8c8a91d9
--- /dev/null
+++ b/tests/nexus-guild-secret-ally-bots.test.ts
@@ -0,0 +1,108 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { applyAction, viewGame } from '../game/engine';
+import { botActions } from '../game/bots';
+import { botHomeworldShipmentPaymentAllowed } from '../game/homeworld-payment-options';
+import { fremenReserveEntry } from '../game/bot-mobility';
+import {
+ nexusGuildSecretAllyFixture,
+ nexusGuildSecretAllyInventory,
+} from './fixture-nexus-guild-secret-ally';
+import { nexusAllow } from './fixture-nexus-cards';
+
+void test('all profiles consider Guild-priced groups before ordinary affordability and retain legal normal fallback', () => {
+ for (const advanced of [false, true])
+ for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
+ const f = nexusGuildSecretAllyFixture({ advanced, spice: 2 }),
+ v = viewGame(f.g, f.owner);
+ v.players[0].bot = profile;
+ const before = structuredClone(v);
+ const actions = botActions(v),
+ action = actions.find(
+ (a) =>
+ a.type === 'ship' &&
+ a.nexus === v.nexusGuildSecretAlly!.event &&
+ Number(a.amount) > 2,
+ );
+ assert.ok(action, profile);
+ assert.ok(botHomeworldShipmentPaymentAllowed(v, action));
+ const next = nexusAllow(applyAction(f.g, f.owner, action));
+ assert.equal(next.players[0].spice, 0);
+ assert.equal(next.players[0].shipped, true);
+ assert.equal(next.players[0].moved, 0);
+ nexusGuildSecretAllyInventory(next);
+ assert.deepEqual(v, before);
+ assert.ok(
+ actions.some((a) => a.type === 'ship' && a.nexus === undefined),
+ );
+ assert.equal(
+ botHomeworldShipmentPaymentAllowed(v, { ...action, nexus: 'stale' }),
+ false,
+ );
+ }
+});
+
+void test('Fremen policies keep free reinforcement and only spend Guild Nexus on a paid route beyond native range', () => {
+ for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
+ const f = nexusGuildSecretAllyFixture({
+ ownerFaction: 'fremen',
+ advanced: true,
+ spice: 20,
+ }),
+ v = viewGame(f.g, f.owner);
+ v.players[0].bot = profile;
+ const actions = botActions(v),
+ free = actions.find((a) => a.type === 'ship' && a.nexus === undefined);
+ assert.ok(free, profile);
+ assert.ok(fremenReserveEntry(String(free.territory)));
+ const paid = actions.find(
+ (a) => a.type === 'ship' && a.nexus === v.nexusGuildSecretAlly!.event,
+ );
+ assert.ok(paid, profile);
+ assert.equal(fremenReserveEntry(String(paid.territory)), false);
+ assert.notEqual(paid.sector, v.storm);
+ assert.ok(
+ actions.every(
+ (a) => !(a.type === 'guildShip' && a.from === 'reserves' && a.nexus),
+ ),
+ );
+ const next = nexusAllow(applyAction(f.g, f.owner, paid));
+ nexusGuildSecretAllyInventory(next);
+ const zero = nexusGuildSecretAllyFixture({
+ ownerFaction: 'fremen',
+ spice: 0,
+ }),
+ zv = viewGame(zero.g, zero.owner);
+ zv.players[0].bot = profile;
+ assert.ok(botActions(zv).every((a) => a.nexus === undefined));
+ }
+});
+
+void test('all profiles can select a physically typed cross-shipment with the explicit source and never borrow flexible Guild timing', () => {
+ for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
+ const f = nexusGuildSecretAllyFixture({
+ ownerFaction: 'emperor',
+ advanced: true,
+ }),
+ p = f.g.players[0];
+ // Conserved pre-declaration position, independent of the Nexus receipt.
+ p.forces['the_great_flat:15'] = 5;
+ p.reserves -= 5;
+ p.elites!.forces['the_great_flat:15'] = 2;
+ p.elites!.reserves -= 2;
+ const v = viewGame(f.g, f.owner);
+ v.players[0].bot = profile;
+ const action = botActions(v).find(
+ (a) =>
+ a.type === 'guildShip' && a.nexus === v.nexusGuildSecretAlly!.event,
+ );
+ assert.ok(action, profile);
+ const next = nexusAllow(applyAction(f.g, f.owner, action));
+ nexusGuildSecretAllyInventory(next);
+ assert.equal(next.players[0].moved, 0);
+ assert.equal(next.players[0].shipped, true);
+ const other = viewGame(f.g, f.target);
+ other.players.find((p) => p.id === f.target)!.bot = profile;
+ assert.ok(botActions(other).every((a) => a.nexus === undefined));
+ }
+});
diff --git a/tests/nexus-guild-secret-ally-controls.test.ts b/tests/nexus-guild-secret-ally-controls.test.ts
new file mode 100644
index 00000000..1ac22525
--- /dev/null
+++ b/tests/nexus-guild-secret-ally-controls.test.ts
@@ -0,0 +1,340 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { registerHooks } from 'node:module';
+import { readFileSync } from 'node:fs';
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { applyAction, viewGame, type GameView } from '../game/engine';
+import { MOBILE_STRONGHOLD, gameTerritories } from '../game/board';
+import { HomeworldShipment } from '../components/homeworld-shipment';
+import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
+import { guildTransportQuote } from '../game/transport-quote';
+import { withNativeShipmentSources } from '../game/homeworld-options';
+import { homeworldGameIntegrity } from '../game/homeworld-game';
+import {
+ nexusGuildSecretAllyAction,
+ nexusGuildSecretAllyCanAct,
+ nexusGuildSecretAllyQuote,
+} from '../game/nexus-guild-secret-ally-options';
+import {
+ nexusGuildSecretAllyFixture,
+ nexusGuildSecretAllyInventory,
+ nexusGuildSecretAllyReload,
+} from './fixture-nexus-guild-secret-ally';
+import { nexusAllow } from './fixture-nexus-cards';
+const aliases = registerHooks({
+ resolve(specifier, context, next) {
+ return next(
+ specifier === 'next/image'
+ ? 'vinext/shims/image'
+ : specifier === 'next/link'
+ ? 'vinext/shims/link'
+ : specifier,
+ context,
+ );
+ },
+ load(url, context, next) {
+ if (!url.endsWith('.module.css')) return next(url, context);
+ const css = readFileSync(new URL(url), 'utf8');
+ const classes = Object.fromEntries(
+ [...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map((m) => [m[1], m[1]]),
+ );
+ return {
+ format: 'module',
+ source: `export default ${JSON.stringify(classes)}`,
+ shortCircuit: true,
+ };
+ },
+});
+const { GameTable } = await import('../components/game-table');
+aliases.deregister();
+
+const html = (game: GameView, busy = false) =>
+ renderToStaticMarkup(
+ createElement(GameTable, {
+ game,
+ busy,
+ send: async () => {},
+ onExit() {},
+ }),
+ );
+
+void test('Basic and Advanced private shipment panel defaults to ordinary price; explicit Guild source spends once without another action', () => {
+ for (const advanced of [false, true]) {
+ const f = nexusGuildSecretAllyFixture({ advanced, spice: 3 });
+ const v = viewGame(f.g, f.owner),
+ before = structuredClone(v);
+ const checkbox = html(v).match(
+ / ]*type="checkbox"[^>]*>[\s\S]{0,150}Use Guild Nexus Secret Ally/,
+ )?.[0];
+ assert.ok(checkbox);
+ assert.doesNotMatch(checkbox, /checked=""/);
+ assert.match(
+ html(v, true).match(
+ / ]*type="checkbox"[^>]*>[\s\S]{0,150}Use Guild Nexus Secret Ally/,
+ )![0],
+ /disabled=""/,
+ );
+ assert.match(html(v), /normal movement remains available/);
+ assert.equal(nexusGuildSecretAllyQuote(v, 'arrakeen', 5)!.cost, 3);
+ assert.equal(nexusGuildSecretAllyQuote(v, 'arrakeen', 4)!.cost, 2);
+ const action = nexusGuildSecretAllyAction(
+ v,
+ v.nexusGuildSecretAlly!.event,
+ { type: 'ship', territory: 'arrakeen', sector: 10, amount: 5 },
+ )!;
+ assert.ok(action);
+ const next = nexusAllow(applyAction(f.g, f.owner, action));
+ const me = next.players.find((p) => p.id === f.owner)!;
+ assert.equal(me.spice, 0);
+ assert.equal(me.reserves, 15);
+ assert.equal(me.shipped, true);
+ assert.equal(me.moved, 0);
+ assert.equal(viewGame(next, f.owner).nexusCards!.card, null);
+ assert.equal(
+ nexusGuildSecretAllyAction(
+ viewGame(next, f.owner),
+ String(action.nexus),
+ action,
+ ),
+ null,
+ );
+ nexusGuildSecretAllyInventory(next);
+ assert.deepEqual(v, before);
+ }
+});
+
+void test('Fremen free native reinforcement remains distinct from explicit paid beyond-range shipping and its storm exclusion', () => {
+ const f = nexusGuildSecretAllyFixture({
+ ownerFaction: 'fremen',
+ advanced: true,
+ });
+ const v = viewGame(f.g, f.owner),
+ event = v.nexusGuildSecretAlly!.event;
+ assert.match(html(v), /Leave this unchecked for free Fremen reinforcement/);
+ const free = nexusAllow(
+ applyAction(nexusGuildSecretAllyReload(f.g), f.owner, {
+ type: 'ship',
+ territory: 'the_great_flat',
+ sector: 15,
+ amount: 5,
+ }),
+ );
+ assert.equal(free.players[0].spice, 20);
+ assert.equal(viewGame(free, f.owner).nexusCards!.card, 'guild');
+ const paid = nexusGuildSecretAllyAction(v, event, {
+ type: 'ship',
+ territory: 'arrakeen',
+ sector: 10,
+ amount: 5,
+ })!;
+ const next = nexusAllow(applyAction(f.g, f.owner, paid));
+ assert.equal(next.players[0].spice, 17);
+ assert.equal(next.players[0].forces['arrakeen:10'], 5);
+ const storm = nexusGuildSecretAllyReload(f.g);
+ storm.storm = 10;
+ const before = structuredClone(storm);
+ assert.throws(() => applyAction(storm, f.owner, paid), /storm/i);
+ assert.deepEqual(storm, before);
+ nexusGuildSecretAllyInventory(free);
+ nexusGuildSecretAllyInventory(next);
+});
+
+void test('borrowed cross and return quotes preserve typed physical groups; no native Guild identity or extra shipment is created', () => {
+ for (const destination of ['carthag', 'reserves']) {
+ const f = nexusGuildSecretAllyFixture({
+ ownerFaction: 'emperor',
+ advanced: true,
+ });
+ // Conserved pre-declaration Arrakis staging; all typed counters retain their real faction.
+ const p = f.g.players[0];
+ p.reserves -= 5;
+ p.forces['hagga_basin:12'] = 5;
+ p.elites!.reserves -= 2;
+ p.elites!.forces['hagga_basin:12'] = 2;
+ const v = viewGame(f.g, f.owner),
+ action = nexusGuildSecretAllyAction(v, v.nexusGuildSecretAlly!.event, {
+ type: 'guildShip',
+ from: 'hagga_basin:12',
+ territory: destination,
+ sector: destination === 'reserves' ? 0 : 11,
+ amount: 5,
+ elite: 2,
+ })!;
+ assert.ok(action);
+ const quote = guildTransportQuote(v, action);
+ assert.deepEqual(quote.unavailableReasons, []);
+ assert.equal(quote.quote!.cost, 3);
+ const next = nexusAllow(applyAction(f.g, f.owner, action)),
+ me = next.players[0];
+ assert.equal(me.faction, 'emperor');
+ assert.equal(me.spice, 17);
+ assert.equal(me.shipped, true);
+ assert.equal(me.moved, 0);
+ assert.equal(me.reserves, destination === 'reserves' ? 20 : 15);
+ assert.equal(me.elites!.reserves, destination === 'reserves' ? 5 : 3);
+ assert.match(
+ guildTransportQuote(v, {
+ ...action,
+ nexus: 'stale',
+ }).unavailableReasons.join(' '),
+ /current Guild/,
+ );
+ assert.match(
+ guildTransportQuote(v, {
+ ...action,
+ territory: 'hagga_basin',
+ sector: 12,
+ }).unavailableReasons.join(' '),
+ /another territory/,
+ );
+ nexusGuildSecretAllyInventory(next);
+ }
+});
+
+void test('Homeworld selection discounts independently legal typed custody; own world and Arrakis return remain unavailable', () => {
+ const f = nexusGuildSecretAllyFixture({
+ ownerFaction: 'emperor',
+ advanced: true,
+ homeworlds: true,
+ spice: 3,
+ });
+ const v = viewGame(f.g, f.owner),
+ worlds = v.homeworlds!.worlds!;
+ const native = worlds.filter((w) => w.native === f.owner),
+ target = worlds.find((w) => w.native === f.target)!;
+ const selection = Object.fromEntries(
+ native.map((w) => [
+ w.id,
+ w.secondary ? { normal: 0, elite: 2 } : { normal: 3, elite: 0 },
+ ]),
+ );
+ const plain = homeworldShipmentChoice(v, target.id, selection);
+ assert.equal(plain.cost, 5);
+ assert.equal(plain.action, null);
+ const discounted = homeworldShipmentChoice(
+ v,
+ target.id,
+ selection,
+ 0,
+ v.nexusGuildSecretAlly!.event,
+ );
+ assert.equal(discounted.cost, 3);
+ assert.equal(discounted.amount, 5);
+ assert.equal(discounted.elite, 2);
+ assert.ok(discounted.action);
+ assert.match(
+ renderToStaticMarkup(
+ createElement(HomeworldShipment, { game: v, act() {}, busy: false }),
+ ),
+ /Use Guild Nexus Secret Ally for Homeworld shipment/,
+ );
+ const next = nexusAllow(applyAction(f.g, f.owner, discounted.action));
+ assert.equal(next.players[0].spice, 0);
+ assert.equal(next.players[0].reserves, 15);
+ homeworldGameIntegrity(next);
+ assert.equal(
+ homeworldShipmentChoice(
+ v,
+ native[0].id,
+ selection,
+ 0,
+ v.nexusGuildSecretAlly!.event,
+ ).action,
+ null,
+ );
+ assert.equal(nexusGuildSecretAllyQuote(v, 'reserves', 5), null);
+ assert.equal(
+ nexusGuildSecretAllyAction(v, v.nexusGuildSecretAlly!.event, {
+ type: 'guildHomeworldShip',
+ amount: 5,
+ }),
+ null,
+ );
+ const reserve = withNativeShipmentSources(
+ v,
+ nexusGuildSecretAllyAction(v, v.nexusGuildSecretAlly!.event, {
+ type: 'ship',
+ territory: 'arrakeen',
+ sector: 10,
+ amount: 5,
+ elite: 2,
+ })!,
+ )!;
+ assert.ok(reserve.homeworldSources);
+ nexusGuildSecretAllyInventory(nexusAllow(applyAction(f.g, f.owner, reserve)));
+});
+
+void test('current source fences reject stale, foreign, interrupted and concealed routes without inspecting another private hand', () => {
+ const f = nexusGuildSecretAllyFixture(),
+ v = viewGame(f.g, f.owner),
+ event = v.nexusGuildSecretAlly!.event;
+ assert.equal(nexusGuildSecretAllyCanAct(v), true);
+ assert.equal(
+ nexusGuildSecretAllyAction(v, event, {
+ type: 'ship',
+ territory: 'arrakeen',
+ sector: 10,
+ amount: 1,
+ noField: 'hidden',
+ }),
+ null,
+ );
+ assert.equal(
+ nexusGuildSecretAllyAction(v, event, {
+ type: 'guildShip',
+ from: 'reserves',
+ territory: 'arrakeen',
+ sector: 10,
+ amount: 1,
+ }),
+ null,
+ );
+ assert.equal(
+ nexusGuildSecretAllyAction(v, 'stale', {
+ type: 'ship',
+ territory: 'arrakeen',
+ sector: 10,
+ amount: 1,
+ }),
+ null,
+ );
+ for (const id of [f.target, f.observer]) {
+ const other = viewGame(f.g, id);
+ assert.equal(other.nexusGuildSecretAlly, null);
+ Object.defineProperty(
+ other.players.find((p) => p.id === f.owner)!,
+ 'hand',
+ {
+ get() {
+ throw new Error('other hand read');
+ },
+ },
+ );
+ assert.equal(nexusGuildSecretAllyCanAct(other), false);
+ assert.doesNotMatch(html(other), /Use Guild Nexus Secret Ally/);
+ }
+ v.nexusGuildSecretAlly!.blocked = 'Finish response';
+ assert.equal(nexusGuildSecretAllyCanAct(v), false);
+});
+
+void test('native Ixian HMS reserve access uses the current board while another holder gains no mobile-stronghold permission', () => {
+ const f = nexusGuildSecretAllyFixture({
+ ownerFaction: 'ixians',
+ advanced: true,
+ }),
+ v = viewGame(f.g, f.owner);
+ const hms = gameTerritories(v).find((t) => t.id === MOBILE_STRONGHOLD)!;
+ const action = nexusGuildSecretAllyAction(v, v.nexusGuildSecretAlly!.event, {
+ type: 'ship',
+ territory: hms.id,
+ sector: hms.sectors[0],
+ amount: 5,
+ elite: 2,
+ })!;
+ assert.ok(action);
+ nexusGuildSecretAllyInventory(nexusAllow(applyAction(f.g, f.owner, action)));
+ const other = nexusGuildSecretAllyFixture(),
+ ov = viewGame(other.g, other.owner);
+ assert.equal(nexusGuildSecretAllyQuote(ov, MOBILE_STRONGHOLD, 1), null);
+});
diff --git a/tests/nexus-guild-secret-ally-engine.test.ts b/tests/nexus-guild-secret-ally-engine.test.ts
new file mode 100644
index 00000000..5bdaeaa0
--- /dev/null
+++ b/tests/nexus-guild-secret-ally-engine.test.ts
@@ -0,0 +1,306 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ applyAction,
+ normalizeAutomaticGame,
+ viewGame,
+ type Action,
+ type Game,
+} from '../game/engine';
+import {
+ nexusGuildSecretAllyFixture,
+ nexusGuildSecretAllyRequest,
+ nexusGuildSecretAllyInventory,
+ nexusGuildSecretAllyReload,
+ holdGuildSecretAllyCard,
+ nexusGuildSecretAllianceFixture,
+} from './fixture-nexus-guild-secret-ally';
+import { nexusAllow } from './fixture-nexus-cards';
+function rejected(g: Game, owner: string, action: Action) {
+ const before = structuredClone(g);
+ assert.throws(() => applyAction(g, owner, action));
+ assert.deepEqual(g, before);
+}
+function stable(g: Game) {
+ nexusGuildSecretAllyInventory(g);
+ for (const p of g.players)
+ assert.deepEqual(
+ viewGame(nexusGuildSecretAllyReload(g), p.id),
+ viewGame(g, p.id),
+ );
+ assert.deepEqual(
+ normalizeAutomaticGame(nexusGuildSecretAllyReload(g)),
+ nexusGuildSecretAllyReload(g),
+ );
+}
+
+void test('Guild Secret Ally charges the native tariff once without a Guild response and retains ordinary faction movement', () => {
+ for (const advanced of [false, true])
+ for (const desert of [false, true]) {
+ const f = nexusGuildSecretAllyFixture({ advanced });
+ const action = nexusGuildSecretAllyRequest(f.g, f.owner, {
+ type: 'ship',
+ territory: desert ? 'hagga_basin' : 'arrakeen',
+ sector: desert ? 12 : 10,
+ amount: 5,
+ });
+ let g = applyAction(f.g, f.owner, action);
+ assert.equal(g.players[0].spice, desert ? 15 : 17);
+ assert.equal(g.players[0].reserves, 15);
+ assert.equal(g.players[0].shipped, true);
+ assert.equal(g.players[0].moved, 0);
+ assert.equal(g.response, null);
+ assert.equal(g.decision, null);
+ assert.equal(g.nexusCards!.cards!.hands[f.owner], null);
+ assert.equal(
+ g.nexusCards!.cards!.discard.filter((c) => c === 'guild').length,
+ 1,
+ );
+ rejected(g, f.owner, action);
+ g = applyAction(nexusGuildSecretAllyReload(g), f.owner, {
+ type: 'move',
+ from: desert ? 'hagga_basin:12' : 'arrakeen:10',
+ territory: desert ? 'arsunt' : 'hagga_basin',
+ sector: 12,
+ amount: 5,
+ });
+ assert.equal(g.players[0].moved, 1);
+ stable(g);
+ }
+});
+
+void test('Guild Secret Ally cross and return preserve actual Emperor normal and Sardaukar counters', () => {
+ for (const returns of [false, true]) {
+ const f = nexusGuildSecretAllyFixture({
+ ownerFaction: 'emperor',
+ advanced: true,
+ });
+ const p = f.g.players[0];
+ p.reserves -= 5;
+ p.elites!.reserves -= 2;
+ p.forces['arrakeen:10'] = 5;
+ p.elites!.forces['arrakeen:10'] = 2;
+ stable(f.g);
+ const action = nexusGuildSecretAllyRequest(f.g, f.owner, {
+ type: 'guildShip',
+ from: 'arrakeen:10',
+ territory: returns ? 'reserves' : 'carthag',
+ sector: returns ? 0 : 11,
+ amount: 5,
+ elite: 2,
+ });
+ const g = applyAction(f.g, f.owner, action);
+ assert.equal(g.players[0].spice, 17);
+ assert.equal(g.players[0].reserves, returns ? 20 : 15);
+ assert.equal(g.players[0].elites!.reserves, returns ? 5 : 3);
+ assert.equal(g.players[0].forces['arrakeen:10'], undefined);
+ if (!returns) {
+ assert.equal(g.players[0].forces['carthag:11'], 5);
+ assert.equal(g.players[0].elites!.forces['carthag:11'], 2);
+ }
+ rejected(g, f.owner, action);
+ stable(g);
+ }
+});
+
+void test('Fremen paid Guild reserve shipment has no free-reinforcement range or storm privilege', () => {
+ const f = nexusGuildSecretAllyFixture({
+ ownerFaction: 'fremen',
+ advanced: true,
+ homeworlds: true,
+ });
+ rejected(f.g, f.owner, {
+ type: 'ship',
+ territory: 'arrakeen',
+ sector: 10,
+ amount: 5,
+ });
+ const action = nexusGuildSecretAllyRequest(f.g, f.owner, {
+ type: 'ship',
+ territory: 'arrakeen',
+ sector: 10,
+ amount: 5,
+ elite: 2,
+ });
+ const storm = structuredClone(f.g);
+ storm.storm = 10;
+ rejected(storm, f.owner, action);
+ rejected(
+ f.g,
+ f.owner,
+ nexusGuildSecretAllyRequest(f.g, f.owner, {
+ type: 'guildShip',
+ from: 'reserves',
+ territory: 'arrakeen',
+ sector: 10,
+ amount: 5,
+ }),
+ );
+ const g = applyAction(f.g, f.owner, action);
+ assert.equal(g.players[0].spice, 17);
+ assert.equal(g.players[0].reserves, 15);
+ assert.equal(g.players[0].elites!.reserves, 1);
+ assert.equal(g.players[0].elites!.forces['arrakeen:10'], 2);
+ stable(g);
+ const ordinary = nexusGuildSecretAllyFixture({
+ ownerFaction: 'fremen',
+ advanced: true,
+ });
+ const free = applyAction(ordinary.g, ordinary.owner, {
+ type: 'ship',
+ territory: 'the_great_flat',
+ sector: 15,
+ amount: 5,
+ });
+ assert.equal(free.players[0].spice, 20);
+ assert.equal(free.nexusCards!.cards!.hands[ordinary.owner], 'guild');
+ stable(free);
+});
+
+void test('Guild Secret Ally reserve and interplanetary shipments withdraw exact native Homeworld types', () => {
+ for (const interplanetary of [false, true]) {
+ const f = nexusGuildSecretAllyFixture({
+ ownerFaction: 'emperor',
+ advanced: true,
+ homeworlds: true,
+ });
+ const sources = {
+ 'homeworld:emperor': { normal: 3, elite: 0 },
+ 'homeworld:emperor:salusa': { normal: 0, elite: 2 },
+ };
+ const action = nexusGuildSecretAllyRequest(
+ f.g,
+ f.owner,
+ interplanetary
+ ? {
+ type: 'homeworldShip',
+ event: viewGame(f.g, f.owner).homeworldShipment!.event,
+ destination: 'homeworld:harkonnen',
+ sources,
+ }
+ : {
+ type: 'ship',
+ territory: 'arrakeen',
+ sector: 10,
+ amount: 5,
+ elite: 2,
+ homeworldSources: sources,
+ },
+ );
+ const g = applyAction(f.g, f.owner, action);
+ assert.equal(g.players[0].spice, 17);
+ assert.equal(g.players[0].reserves, 15);
+ assert.equal(g.players[0].elites!.reserves, 3);
+ if (interplanetary)
+ assert.deepEqual(
+ g.homeworlds!.custody!.visitors['homeworld:harkonnen'][f.owner],
+ { normal: 3, elite: 2 },
+ );
+ else assert.equal(g.players[0].elites!.forces['arrakeen:10'], 2);
+ stable(g);
+ }
+});
+
+void test('Guild Secret Ally rejects foreign, stale, duplicated and concealed requests without revealing another hand', () => {
+ const f = nexusGuildSecretAllyFixture();
+ const request = nexusGuildSecretAllyRequest(f.g, f.owner);
+ for (const id of [f.target, f.observer]) {
+ assert.equal(viewGame(f.g, id).nexusGuildSecretAlly, null);
+ rejected(f.g, id, request);
+ }
+ rejected(f.g, f.owner, { ...request, nexus: 'stale' });
+ rejected(f.g, f.owner, { ...request, noField: 'hidden' });
+ const g = applyAction(f.g, f.owner, request);
+ for (const p of g.players)
+ assert.equal(
+ Object.hasOwn(viewGame(g, p.id), 'nexusGuildSecretHistory'),
+ false,
+ );
+ stable(g);
+});
+
+void test('a binding Truthtrance answer can rely on privately held Guild pricing and then fulfill the original shipment', () => {
+ const f = nexusGuildSecretAllyFixture({ spice: 3 });
+ const card = holdGuildSecretAllyCard(f.g, f.target, 'truthtrance');
+ let g = applyAction(f.g, f.target, { type: 'card', card: card.id });
+ while (g.truthtrance?.stage === 'priority')
+ g = applyAction(
+ g,
+ g.players.find((p) => !g.truthtrance!.passed.includes(p.id))!.id,
+ { type: 'truthPass' },
+ );
+ g = applyAction(g, f.target, {
+ type: 'truthAsk',
+ question: {
+ kind: 'shipment',
+ target: f.owner,
+ territory: 'arrakeen',
+ minimum: 5,
+ },
+ });
+ assert.deepEqual(viewGame(g, f.owner).truthShipmentAnswers, ['yes', 'no']);
+ g = applyAction(g, f.owner, { type: 'truthAnswer', answer: 'yes' });
+ const witness = viewGame(g, f.owner).shipmentCompletion!;
+ assert.ok(
+ witness.actions.some(
+ (a) =>
+ a.type === 'ship' &&
+ a.nexus === viewGame(g, f.owner).nexusGuildSecretAlly!.event,
+ ),
+ );
+ for (const id of [f.target, f.observer])
+ assert.equal(viewGame(g, id).shipmentCompletion, null);
+ rejected(g, f.owner, { type: 'endMovement' });
+ rejected(
+ g,
+ f.owner,
+ nexusGuildSecretAllyRequest(g, f.owner, {
+ type: 'ship',
+ territory: 'arrakeen',
+ sector: 10,
+ amount: 4,
+ }),
+ );
+ g = applyAction(
+ nexusGuildSecretAllyReload(g),
+ f.owner,
+ nexusGuildSecretAllyRequest(g, f.owner),
+ );
+ assert.equal(g.players[0].spice, 0);
+ assert.equal(g.players[0].reserves, 15);
+ assert.equal(g.shipmentPromises![0].fulfilled, true);
+ stable(g);
+});
+
+void test('a genuine Moritani arrival alliance preserves completed Guild Secret Ally history and subsequent native movement', () => {
+ const f = nexusGuildSecretAllianceFixture();
+ let g = applyAction(f.g, f.owner, nexusGuildSecretAllyRequest(f.g, f.owner));
+ assert.equal(g.pendingTerrorEntry?.stage, 'offer');
+ const before = structuredClone(g.players[0]),
+ history = structuredClone(g.nexusGuildSecretHistory);
+ g = nexusAllow(
+ applyAction(g, f.target, { type: 'decision', alliance: true }),
+ );
+ stable(g);
+ g = applyAction(nexusGuildSecretAllyReload(g), f.owner, {
+ type: 'decision',
+ accept: true,
+ });
+ assert.equal(g.players[0].ally, f.target);
+ assert.equal(g.players[1].ally, f.owner);
+ assert.deepEqual(g.nexusGuildSecretHistory, history);
+ assert.equal(g.players[0].spice, before.spice);
+ assert.equal(g.players[0].reserves, before.reserves);
+ stable(g);
+ g = applyAction(g, f.owner, {
+ type: 'move',
+ from: 'arrakeen:10',
+ territory: 'hagga_basin',
+ sector: 12,
+ amount: 5,
+ });
+ assert.equal(g.players[0].forces['hagga_basin:12'], 5);
+ g = applyAction(g, f.owner, { type: 'endMovement' });
+ assert.notEqual(g.active, f.owner);
+ stable(g);
+});
diff --git a/tests/nexus-guild-secret-ally-recovery.test.ts b/tests/nexus-guild-secret-ally-recovery.test.ts
new file mode 100644
index 00000000..ae4fbc68
--- /dev/null
+++ b/tests/nexus-guild-secret-ally-recovery.test.ts
@@ -0,0 +1,339 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { unitStore } from './fixture-nexus-room-store';
+import {
+ normalizeAutomaticGame,
+ viewGame,
+ type Action,
+ type Game,
+} from '../game/engine';
+import type { RoomsClock } from '../db/rooms';
+import {
+ nexusGuildSecretAllyFixture,
+ nexusGuildSecretAllyInventory,
+ nexusGuildSecretAllyRequest,
+ nexusGuildSecretAllianceFixture,
+} from './fixture-nexus-guild-secret-ally';
+const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
+async function fixture(homeworlds = false, alliance = false) {
+ const store = unitStore();
+ const made = await store.rooms.createRoom(
+ 'Guild Secret Ally SQL',
+ 'emperor',
+ true,
+ [],
+ );
+ const code = made.view.code,
+ tokens = [made.token];
+ for (const faction of ['harkonnen', 'fremen'] as const)
+ tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
+ const auths = await Promise.all(
+ tokens.map((t) => store.rooms.authenticate(code, t)),
+ );
+ const ids = auths.map((a) => a.playerId) as [string, string, string];
+ const f = alliance
+ ? nexusGuildSecretAllianceFixture(ids)
+ : nexusGuildSecretAllyFixture({
+ seatIds: ids,
+ ownerFaction: 'emperor',
+ advanced: true,
+ homeworlds,
+ });
+ f.g.code = code;
+ f.g.version = (await store.rooms.readRoom(code)).version;
+ const save = (g: Game) =>
+ store.sqlite
+ .prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
+ .run(JSON.stringify(g), g.version, code);
+ save(f.g);
+ store.writes.length = 0;
+ return { ...store, ...f, code, tokens, auths, ids, save };
+}
+type Fixture = Awaited>;
+const row = (f: Fixture) =>
+ f.sqlite
+ .prepare('SELECT state,version FROM rooms WHERE code = ?')
+ .get(f.code);
+async function restored(f: Fixture, g: Game) {
+ const before = row(f),
+ rooms = f.restart();
+ for (const [i, token] of f.tokens.entries()) {
+ const auth = await rooms.authenticate(f.code, token),
+ v = await rooms.readSeatView(f.code, auth);
+ assert.deepEqual(v, viewGame(g, f.ids[i]));
+ assert.equal(Object.hasOwn(v, 'nexusGuildSecretHistory'), false);
+ if (i !== 0) assert.equal(v.nexusGuildSecretAlly, null);
+ }
+ await rooms.continueRoomAutomatic(f.code, clock);
+ assert.deepEqual(row(f), before);
+ nexusGuildSecretAllyInventory(g);
+}
+async function act(f: Fixture, action: Action, seat = 0) {
+ const g = await f.restart().readRoom(f.code);
+ await f.restart().act(f.code, f.auths[seat], g.version, action, clock);
+ return f.restart().readRoom(f.code);
+}
+
+void test('SQLite saves the genuine post-shipment Moritani alliance reply and cannot replay its shipment when accepting', async () => {
+ const f = await fixture(false, true);
+ try {
+ let g = await act(f, nexusGuildSecretAllyRequest(f.g, f.owner));
+ assert.equal(g.pendingTerrorEntry?.stage, 'offer');
+ const spice = g.players[0].spice,
+ reserves = g.players[0].reserves;
+ g = await act(f, { type: 'decision', alliance: true }, 1);
+ while (g.response)
+ g = await act(
+ f,
+ { type: 'passResponse' },
+ f.ids.indexOf(
+ g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
+ ),
+ );
+ await restored(f, g);
+ const accepted = await act(f, { type: 'decision', accept: true });
+ assert.equal(accepted.players[0].ally, f.target);
+ assert.equal(accepted.players[1].ally, f.owner);
+ assert.equal(accepted.players[0].spice, spice);
+ assert.equal(accepted.players[0].reserves, reserves);
+ assert.equal(accepted.nexusGuildSecretHistory!.length, 1);
+ await restored(f, accepted);
+ const before = row(f);
+ await assert.rejects(
+ f
+ .restart()
+ .act(
+ f.code,
+ f.auths[0],
+ accepted.version,
+ { type: 'decision', accept: true },
+ clock,
+ ),
+ );
+ assert.deepEqual(row(f), before);
+ const moved = await act(f, {
+ type: 'move',
+ from: 'arrakeen:10',
+ territory: 'hagga_basin',
+ sector: 12,
+ amount: 5,
+ });
+ assert.equal(moved.players[0].spice, spice);
+ assert.equal(moved.players[0].reserves, reserves);
+ assert.equal(moved.players[0].forces['hagga_basin:12'], 5);
+ await restored(f, moved);
+ const ended = await act(f, { type: 'endMovement' });
+ assert.notEqual(ended.active, f.owner);
+ await restored(f, ended);
+ } finally {
+ f.sqlite.close();
+ }
+});
+
+void test('SQLite racing Guild Secret Ally commits one paid typed shipment and lost acknowledgment cannot repeat it', async () => {
+ for (const homeworlds of [false, true]) {
+ const f = await fixture(homeworlds);
+ try {
+ const other = await f.rooms.createRoom(
+ 'Unrelated SQL room',
+ 'atreides',
+ false,
+ [],
+ );
+ const otherRow = () =>
+ f.sqlite
+ .prepare('SELECT state,version FROM rooms WHERE code = ?')
+ .get(other.view.code);
+ const unrelated = otherRow();
+ const action = nexusGuildSecretAllyRequest(
+ f.g,
+ f.owner,
+ homeworlds
+ ? {
+ type: 'homeworldShip',
+ event: viewGame(f.g, f.owner).homeworldShipment!.event,
+ destination: 'homeworld:harkonnen',
+ sources: {
+ 'homeworld:emperor': { normal: 3, elite: 0 },
+ 'homeworld:emperor:salusa': { normal: 0, elite: 2 },
+ },
+ }
+ : {
+ type: 'ship',
+ territory: 'arrakeen',
+ sector: 10,
+ amount: 5,
+ elite: 2,
+ },
+ );
+ let arrivals = 0,
+ release!: () => void;
+ const gate = new Promise((r) => {
+ release = r;
+ });
+ f.hooks.beforeWrite = async () => {
+ if (++arrivals === 2) release();
+ await gate;
+ };
+ const timer = setTimeout(release, 2000);
+ const results = await Promise.allSettled(
+ [0, 1].map(() =>
+ f.restart().act(f.code, f.auths[0], f.g.version, action, clock),
+ ),
+ );
+ clearTimeout(timer);
+ delete f.hooks.beforeWrite;
+ assert.equal(arrivals, 2);
+ assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1);
+ assert.deepEqual(
+ f.writes.map((w) => w.changes).sort((a, b) => a - b),
+ [0, 1],
+ );
+ const done = await f.restart().readRoom(f.code);
+ assert.equal(done.players[0].spice, 17);
+ assert.equal(done.players[0].reserves, 15);
+ assert.equal(done.players[0].elites!.reserves, 3);
+ assert.equal(done.nexusGuildSecretHistory!.length, 1);
+ assert.equal(
+ done.nexusCards!.cards!.discard.filter((c) => c === 'guild').length,
+ 1,
+ );
+ if (homeworlds)
+ assert.deepEqual(
+ done.homeworlds!.custody!.visitors['homeworld:harkonnen'][f.owner],
+ { normal: 3, elite: 2 },
+ );
+ else assert.equal(done.players[0].elites!.forces['arrakeen:10'], 2);
+ await restored(f, done);
+ const before = row(f);
+ await assert.rejects(
+ f.restart().act(f.code, f.auths[0], done.version, action, clock),
+ );
+ assert.deepEqual(row(f), before);
+ assert.deepEqual(otherRow(), unrelated);
+ } finally {
+ f.sqlite.close();
+ }
+ }
+});
+
+void test('SQLite saved Guild Secret Ally completion preserves native movement and does not reread held Nexus identity', async () => {
+ const f = await fixture();
+ try {
+ const shipped = await act(
+ f,
+ nexusGuildSecretAllyRequest(f.g, f.owner, {
+ type: 'ship',
+ territory: 'arrakeen',
+ sector: 10,
+ amount: 5,
+ elite: 2,
+ }),
+ );
+ await restored(f, shipped);
+ const moved = await act(f, {
+ type: 'move',
+ from: 'arrakeen:10',
+ territory: 'hagga_basin',
+ sector: 12,
+ amount: 5,
+ elite: 2,
+ });
+ assert.equal(moved.players[0].spice, 17);
+ assert.equal(moved.players[0].reserves, 15);
+ assert.equal(moved.players[0].elites!.forces['hagga_basin:12'], 2);
+ assert.equal(moved.players[0].moved, 1);
+ await restored(f, moved);
+ const ended = await act(f, { type: 'endMovement' });
+ assert.notEqual(ended.active, f.owner);
+ await restored(f, ended);
+ } finally {
+ f.sqlite.close();
+ }
+});
+
+void test('SQLite damaged Guild Secret Ally terminal route proofs reject all projections and actions without writes', async () => {
+ const f = await fixture(true);
+ try {
+ const done = await act(
+ f,
+ nexusGuildSecretAllyRequest(f.g, f.owner, {
+ type: 'ship',
+ territory: 'arrakeen',
+ sector: 10,
+ amount: 5,
+ elite: 2,
+ homeworldSources: {
+ 'homeworld:emperor': { normal: 3, elite: 0 },
+ 'homeworld:emperor:salusa': { normal: 0, elite: 2 },
+ },
+ }),
+ );
+ const editFrame = (
+ g: Game,
+ edit: (frame: Record) => void,
+ ) => {
+ const record = g.nexusGuildSecretHistory![0];
+ const frame = JSON.parse(record.frame) as Record;
+ edit(frame);
+ record.frame = JSON.stringify(frame);
+ };
+ for (const edit of [
+ (g: Game) => {
+ delete g.nexusGuildSecretHistory;
+ },
+ (g: Game) => {
+ delete g.nexusGuildSecretLast;
+ },
+ (g: Game) => {
+ g.nexusGuildSecretHistory![0].signature = 'changed';
+ },
+ (g: Game) =>
+ editFrame(g, (frame) => {
+ delete frame.guildSecretEvent;
+ }),
+ (g: Game) =>
+ editFrame(g, (frame) => {
+ frame.amount = 4;
+ }),
+ (g: Game) =>
+ editFrame(g, (frame) => {
+ frame.cost = 1;
+ }),
+ (g: Game) =>
+ editFrame(g, (frame) => {
+ frame.elite = 1;
+ }),
+ (g: Game) =>
+ editFrame(g, (frame) => {
+ frame.homeworldSources = {
+ 'homeworld:emperor': { normal: 5, elite: 0 },
+ };
+ }),
+ ]) {
+ const bad = structuredClone(done);
+ edit(bad);
+ f.save(bad);
+ f.writes.length = 0;
+ const before = row(f),
+ rooms = f.restart();
+ for (const auth of f.auths)
+ await assert.rejects(rooms.readSeatView(f.code, auth));
+ assert.throws(() => normalizeAutomaticGame(bad));
+ await Promise.allSettled([rooms.continueRoomAutomatic(f.code, clock)]);
+ await assert.rejects(
+ rooms.act(
+ f.code,
+ f.auths[0],
+ bad.version,
+ { type: 'endMovement' },
+ clock,
+ ),
+ );
+ assert.deepEqual(row(f), before);
+ assert.equal(f.writes.length, 0);
+ }
+ } finally {
+ f.sqlite.close();
+ }
+});
diff --git a/tests/nexus-guild-secret-ally.test.ts b/tests/nexus-guild-secret-ally.test.ts
new file mode 100644
index 00000000..d6ed2e3a
--- /dev/null
+++ b/tests/nexus-guild-secret-ally.test.ts
@@ -0,0 +1,237 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { FACTIONS } from '../game/catalog';
+import {
+ createNexusGuildSecretAlly,
+ validateNexusGuildSecretAlly,
+ quoteNexusGuildSecretShipment,
+ type NexusGuildSecretAllyContext,
+ type NexusGuildSecretAllyReceipt,
+} from '../game/nexus-guild-secret-ally';
+
+const context: NexusGuildSecretAllyContext = {
+ turn: 4,
+ players: [
+ { id: 'a', faction: 'atreides' },
+ { id: 'e', faction: 'emperor' },
+ { id: 'f', faction: 'fremen' },
+ ],
+};
+const clone = (v: T): T => JSON.parse(JSON.stringify(v));
+
+void test('Guild Secret Ally quotes all physical groups at Guild rates with one upward rounding and no force-count substitution', () => {
+ for (const type of [
+ 'stronghold',
+ 'sand',
+ 'rock',
+ 'polar',
+ 'reserves',
+ 'homeworld',
+ ])
+ for (let amount = 1; amount <= 20; amount++)
+ assert.deepEqual(quoteNexusGuildSecretShipment(type, amount), {
+ physicalAmount: amount,
+ cost: ['stronghold', 'reserves', 'homeworld'].includes(type)
+ ? Math.ceil(amount / 2)
+ : amount,
+ source: 'nexusGuildSecretAlly',
+ });
+ assert.equal(quoteNexusGuildSecretShipment('homeworld', 5).cost, 3);
+ assert.equal(
+ quoteNexusGuildSecretShipment('reserves', 5).cost,
+ 3,
+ 'return tariff is final, not halved a second time',
+ );
+ assert.equal(
+ quoteNexusGuildSecretShipment('sand', 5).cost,
+ 5,
+ 'paid Fremen cross-route must not inherit free native reinforcement',
+ );
+});
+
+void test('physical shipment quote rejects zero, fractional, excess, coerced or unknown destinations', () => {
+ for (const value of [
+ 0,
+ -1,
+ 21,
+ 0.5,
+ NaN,
+ Infinity,
+ Number.MAX_SAFE_INTEGER,
+ '5',
+ null,
+ undefined,
+ ])
+ assert.throws(() =>
+ quoteNexusGuildSecretShipment('stronghold', value as number),
+ );
+ for (const type of [
+ '',
+ 'arrakeen',
+ 'homeworld:atreides',
+ 'space',
+ 'Stronghold',
+ null,
+ undefined,
+ ])
+ assert.throws(() => quoteNexusGuildSecretShipment(type as string, 5));
+});
+
+void test('each non-Guild native faction can own a canonically bound absent-Guild receipt without changing the supplied roster', () => {
+ for (const faction of FACTIONS.filter((f) => f.id !== 'guild')) {
+ const other = FACTIONS.filter(
+ (f) => f.id !== 'guild' && f.id !== faction.id,
+ ).slice(0, 2);
+ const ctx = {
+ turn: 2,
+ players: [
+ { id: 'owner', faction: faction.id },
+ ...other.map((f, i) => ({ id: `other${i}`, faction: f.id })),
+ ],
+ };
+ const before = clone(ctx),
+ receipt = createNexusGuildSecretAlly(ctx, 'owner');
+ assert.equal(
+ receipt.event,
+ JSON.stringify(['nexusGuildSecretAlly', 2, 'owner']),
+ );
+ assert.equal(receipt.phase, 5);
+ assert.equal(receipt.mode, 'secretAlly');
+ assert.deepEqual(ctx, before);
+ assert.notEqual(receipt.roster, ctx.players);
+ validateNexusGuildSecretAlly(ctx, clone(receipt));
+ assert.deepEqual(
+ createNexusGuildSecretAlly(
+ { ...ctx, players: [...ctx.players].reverse() },
+ 'owner',
+ ),
+ receipt,
+ );
+ }
+});
+
+void test('seated Guild, unknown owner, malformed identities and duplicated seats or factions cannot issue receipts', () => {
+ const badRosters = [
+ [...context.players, { id: 'g', faction: 'guild' as const }],
+ [...context.players, context.players[0]],
+ context.players.map((p) => ({ ...p, id: 'same' })),
+ context.players.map((p) => ({ ...p, faction: 'fremen' as const })),
+ context.players.map((p) => ({
+ ...p,
+ id: p.id === 'a' ? '__proto__' : p.id,
+ })),
+ [],
+ ];
+ for (const players of badRosters) {
+ const ctx = { ...context, players },
+ before = clone(ctx);
+ assert.throws(() => createNexusGuildSecretAlly(ctx, 'a'));
+ assert.deepEqual(ctx, before);
+ }
+ assert.throws(() => createNexusGuildSecretAlly(context, 'outsider'));
+ for (const turn of [0, -1, 1.5, NaN, Infinity])
+ assert.throws(() => createNexusGuildSecretAlly({ ...context, turn }, 'a'));
+});
+
+void test('completed historical receipt survives later turns, alliance changes and private custody changes without reading them', () => {
+ const receipt = createNexusGuildSecretAlly(context, 'a');
+ const trap = () => {
+ throw new Error('private state read');
+ };
+ const historical = {
+ turn: 8,
+ players: context.players.map((p) =>
+ Object.freeze({
+ ...p,
+ get hand() {
+ return trap();
+ },
+ get spice() {
+ return trap();
+ },
+ get reserves() {
+ return trap();
+ },
+ get forces() {
+ return trap();
+ },
+ get shipped() {
+ return trap();
+ },
+ get ally() {
+ return trap();
+ },
+ }),
+ ),
+ };
+ validateNexusGuildSecretAlly(historical, receipt);
+ assert.deepEqual(
+ createNexusGuildSecretAlly({ ...historical, turn: 4 }, 'a'),
+ receipt,
+ );
+ assert.throws(() =>
+ validateNexusGuildSecretAlly({ ...historical, turn: 3 }, receipt),
+ );
+ assert.throws(() =>
+ validateNexusGuildSecretAlly(
+ { ...historical, players: historical.players.slice(0, 2) },
+ receipt,
+ ),
+ );
+});
+
+void test('exact owner, event, roster, phase and signature corruption rejects immutably after JSON recovery', () => {
+ const receipt = createNexusGuildSecretAlly(context, 'a');
+ const changes: ((r: NexusGuildSecretAllyReceipt) => void)[] = [
+ (r) => {
+ r.owner = 'e';
+ },
+ (r) => {
+ r.turn = 3;
+ },
+ (r) => {
+ r.event = 'old';
+ },
+ (r) => {
+ r.signature = '';
+ },
+ (r) => {
+ Object.assign(r, { phase: 4 });
+ },
+ (r) => {
+ Object.assign(r, { mode: 'cunning' });
+ },
+ (r) => {
+ Object.assign(r, { version: 2 });
+ },
+ (r) => {
+ r.roster.reverse();
+ },
+ (r) => {
+ r.roster[0].id = 'foreign';
+ },
+ (r) => {
+ r.roster[0].faction = 'guild';
+ },
+ (r) => {
+ delete (r as Partial).signature;
+ },
+ (r) => {
+ Object.assign(r, { cost: 0 });
+ },
+ ];
+ for (const change of changes) {
+ const bad = clone(receipt);
+ change(bad);
+ const before = clone(bad);
+ assert.throws(() => validateNexusGuildSecretAlly(context, bad));
+ assert.deepEqual(bad, before);
+ }
+ const inherited = Object.assign(
+ Object.create({ untrusted: true }),
+ receipt,
+ ) as NexusGuildSecretAllyReceipt;
+ assert.throws(() => validateNexusGuildSecretAlly(context, inherited));
+ assert.equal(JSON.stringify(receipt).includes('hand'), false);
+ assert.equal(JSON.stringify(receipt).includes('spice'), false);
+});