diff --git a/components/game-table.tsx b/components/game-table.tsx
index cd5339b6..26f26056 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -1,4 +1,5 @@
'use client';
+import { nexusGuildCunningAction, nexusGuildCunningActive, nexusGuildMovementAvailable, nexusGuildShipmentAvailable, nexusGuildSkipShipmentAction } from '@/game/nexus-guild-cunning-options';
import {
matchesShipment,
liveShipmentPromises,
@@ -169,6 +170,10 @@ export function GameTable({
onExit: () => void;
}) {
const me = g.players.find((p) => p.id === g.me)!;
+ const shipmentAvailable = nexusGuildShipmentAvailable(g);
+ const movementAvailable = nexusGuildMovementAvailable(g);
+ const guildCunning = nexusGuildCunningActive(g);
+ const guildCunningAction = g.nexusGuildCunning?.offer ? nexusGuildCunningAction(g, g.nexusGuildCunning.offer.event) : null;
const combatName = (id: string) =>
g.combatLocations?.find((place) => place.id === id)?.name ??
gameTerritories(g).find((place) => place.id === id)?.name ??
@@ -594,15 +599,15 @@ export function GameTable({
amount,
};
const transportPreview =
- guildTransport && !me.shipped
+ guildTransport && shipmentAvailable
? guildTransportQuote(g, transportAction)
: null;
const southernTransportPreview =
- guildTransport && !me.shipped && me.faction === 'fremen'
+ guildTransport && shipmentAvailable && me.faction === 'fremen'
? guildTransportQuote(g, southernTransportAction)
: null;
const returnTransportPreview =
- guildTransport && !me.shipped && me.faction === 'guild'
+ guildTransport && shipmentAvailable && me.faction === 'guild'
? guildTransportQuote(g, returnTransportAction)
: null;
const canceledFremenRouteBlocked =
@@ -656,7 +661,7 @@ export function GameTable({
const promises = liveShipmentPromises(g.shipmentPromises, me.id, g.turn);
const shipmentBlocked =
g.phase === 5 &&
- !me.shipped &&
+ shipmentAvailable &&
['ship', 'guildShip', 'move', 'endMovement'].includes(a.type) &&
promises.some(
(p) =>
@@ -1818,6 +1823,7 @@ export function GameTable({
capture: 'Harkonnen leader capture',
kwisatz: 'Kwisatz Haderach battle protection',
guildTiming: 'Guild shipment and movement timing',
+ nexusGuildCunning: 'Guild Cunning second shipment',
atreidesAuction: 'Atreides auction foresight',
atreidesSpice: 'Atreides spice foresight',
stormPeek: 'Fremen storm foresight',
@@ -1983,7 +1989,12 @@ export function GameTable({
unchanged.
)}
+ {g.response.kind === 'nexusGuildCunning' &&
+ Allow one second Guild shipment at its normal price, or cancel this extra opportunity.
+ The completed first shipment and movement stay unchanged; the Nexus is already spent.
+
}
{g.response.intent &&
+ g.response.kind !== 'nexusGuildCunning' &&
g.response.source !== 'ambassador' &&
g.response.kind !== 'nexusAdvisorFlip' &&
g.response.kind !== 'nexusSardaukar' &&
@@ -3594,10 +3605,10 @@ export function GameTable({
{g.phase === 5 && (
= (me.movesAllowed ?? 1) ||
(includesNoField
? !validMarkerGroup
@@ -3916,7 +3928,11 @@ export function GameTable({
}
/>
- {(me.moved ?? 0) < (me.movesAllowed ?? 1)
+ {guildCunning?.stage === 'secondShipment'
+ ? 'Choose or decline the second shipment before any Hajr movement.'
+ : guildCunning?.stage === 'extraMove'
+ ? `${guildCunning.movesLeft} unused Hajr movement remaining. No ordinary movement is granted.`
+ : (me.moved ?? 0) < (me.movesAllowed ?? 1)
? `${(me.movesAllowed ?? 1) - (me.moved ?? 0)} movement remaining.`
: 'Movement complete. Finish your turn when your shipment is also settled.'}
@@ -3967,9 +3983,24 @@ export function GameTable({
)}
>
)}
- {actionButton('Finish shipment & movement', {
- type: 'endMovement',
- })}
+ {g.nexusGuildCunning?.offer && (
+
+ Guild Cunning ends your ordinary shipment and movement, then offers one second shipment at normal Guild prices. Only an unused Hajr move can follow it. The physical Ornithopter card combination remains unavailable.
+ {g.nexusGuildCunning.offer.blocked && {g.nexusGuildCunning.offer.blocked}
}
+ {actionButton('Finish ordinary turn and declare Guild Cunning',
+ guildCunningAction ?? {type: 'endMovement'}, !guildCunningAction)}
+
+ )}
+ {guildCunning && {guildCunning.stage === 'secondShipment'
+ ? 'Guild Cunning: choose your second shipment, or skip it and retain an unused Hajr move. This does not reopen ordinary movement.'
+ : guildCunning.stage === 'extraMove'
+ ? 'The second shipment is settled. Use an unused Hajr move or finish your turn.'
+ : 'Guild Cunning awaits its cancellation response.'}
}
+ {guildCunning?.stage === 'secondShipment' && actionButton('Skip second shipment',
+ nexusGuildSkipShipmentAction(g, guildCunning.event) ?? {type: 'endMovement'},
+ !nexusGuildSkipShipmentAction(g, guildCunning.event))}
+ {actionButton(guildCunning?.stage === 'secondShipment' ? 'Decline second shipment and finish' :
+ guildCunning ? 'Finish Guild turn' : 'Finish shipment & movement', {type: 'endMovement'})}
>
) : (
@@ -4922,7 +4953,7 @@ export function GameTable({
{canUseAsKarama(g.advanced, me.faction, c) &&
g.phase === 5 &&
g.active &&
- !g.players.find((p) => p.id === g.active)?.shipped && (
+ (g.active === me.id ? shipmentAvailable : !g.players.find((p) => p.id === g.active)?.shipped) && (
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. 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. 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 b23ce63c..42f5fc1b 100644
--- a/docs/COMPONENT_INVENTORY.md
+++ b/docs/COMPONENT_INVENTORY.md
@@ -1,5 +1,7 @@
# Component inventory and verification ledger
+**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.
**Moritani Nexus Cunning update, 10 September 2026:** existing supply tokens now have an explicit Cunning choice for placement on the ordinary Arrakis map, including existing stacks. The physical token inspector and hidden public map marker remain shared; no extra token or artwork is created. Separate current and historical placement evidence supports later entry, revelation and discard recovery without exposing faces. [NEXUS_MORITANI_RUNTIME.md](NEXUS_MORITANI_RUNTIME.md) records 27 new regressions and desktop/phone verification. Enhanced relocation, mobile-stronghold placement, Grumman combinations and complete faction/module acceptance remain unresolved.
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index 2232ea54..0d0926e1 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,7 @@
# Dune implementation status
+**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.
**Moritani Nexus Cunning checkpoint, 10 September 2026:** [NEXUS_MORITANI_RUNTIME.md](NEXUS_MORITANI_RUNTIME.md) records Basic/Advanced supply placement throughout the printed Arrakis map, including existing Terror stacks. Shared controls and all four bot profiles use the private offer; ordinary relocation stays unchanged. Native Karama cancellation preserves the token and spends the opportunity/Nexus. Independent review fixed arrival-signature downgrade, stale former-location reuse and physical-face rebinding; current placement permission and historical entry evidence remain separate. **Types, lint, all 4,069 offline tests, production build and 40 HTTP/session tests pass.** Twenty-seven new cases include real later shipments, Robbery/Sabotage/Sneak Attack, Truthtrance, prior Grumman outcomes and concurrent SQLite recovery. Two three-seat browser rooms passed phone placement, allowance/cancellation, map markers and all six private refreshes. All **3,516** opening room versions/hashes remain unchanged; two browser and thirty HTTP rooms were added. Relocation/HMS/Grumman interpretations, other unfinished Terror/Nexus effects and full module acceptance remain gated. The new [Ecaz source audit](NEXUS_ECAZ_RULES.md) records its leader-scope and disclosure boundaries. Existing server reused, no restart due; automation remains removed.
diff --git a/docs/NEXUS_CARD_RUNTIME.md b/docs/NEXUS_CARD_RUNTIME.md
index bb420e91..5d574c75 100644
--- a/docs/NEXUS_CARD_RUNTIME.md
+++ b/docs/NEXUS_CARD_RUNTIME.md
@@ -2,6 +2,8 @@
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.
+
**Historical foundation checkpoint.** Subsequent Atreides inspection integration is recorded in [Atreides runtime](NEXUS_ATREIDES_RUNTIME.md). The counts and effect limitations below describe the original lifecycle checkpoint.
Integration record, 10 September 2026. **Types, lint, all 3,795 offline tests, the production build and 40 HTTP/session tests pass.** This checkpoint implements the optional Nexus card inventory and common draw/keep/replace lifecycle, plus a complete inspectable reference. It does **not** implement the thirty-six card effects, enable a complete public Nexus module, or certify Ecaz & Moritani or all expansions. Source facts, photographed component faces, and unresolved effect interactions are in [NEXUS_CARD_RULES.md](NEXUS_CARD_RULES.md).
diff --git a/docs/NEXUS_GUILD_CUNNING_RUNTIME.md b/docs/NEXUS_GUILD_CUNNING_RUNTIME.md
new file mode 100644
index 00000000..2fef6555
--- /dev/null
+++ b/docs/NEXUS_GUILD_CUNNING_RUNTIME.md
@@ -0,0 +1,49 @@
+# Guild Nexus Cunning runtime
+
+This checkpoint follows the [Guild source contract](NEXUS_GUILD_RULES.md): native Guild can spend Cunning when finishing its ordinary Shipment and Movement turn to obtain one second shipment. It preserves one combined turn and the actual history of the first shipment and movement. It does not implement Guild Secret Ally, Betrayal or complete Nexus play.
+
+## Opportunity and controls
+
+The [shared consumer helper](../game/nexus-guild-cunning-options.ts) binds the private native holder, unallied card, current turn, actor and projected event. The [table](../components/game-table.tsx) offers an explicit “Finish ordinary turn and declare Guild Cunning” action alongside ordinary finishing. It spends the card and opens the existing Karama response mechanism. A cancellation ends the original turn without undoing its committed shipment or movement; allowance offers the separate shipment.
+
+The original player's `shipped` flag remains unchanged. Source-aware availability recognizes only the current signed second-shipment stage, and the existing [reserve](../game/homeworld-shipment-options.ts), [Guild Homeworld](../game/guild-homeworld-shipment-options.ts) and [cross-shipment](../game/transport-quote.ts) consumer quotes use that shared availability. They retain their physical source, price, typed-counter, storm and destination checks. The native cross-shipment validator and preview now also reject returning to the same territory, a pre-existing mismatch that bots already avoided. The extra shipment uses ordinary action fields; the engine associates them with the durable grant. No consumer clears flags or invents custody.
+
+The player can skip only the second shipment and retain the separate unused Hajr choice, or decline the shipment and finish the whole turn. Existing responses and decisions block a new shipment even while its original stage remains active for restoration. Opponents receive no private offer or actionable owner descriptor. Public entry, payment and cancellation controls preserve their existing information boundaries.
+
+## Saved state and authoritative settlement
+
+A signed original receipt records the native owner, turn, roster and first shipment/movement/Hajr status. A separate lifecycle record keeps the original combined-turn queue, stage, consumed extra movement, and exact second-shipment frame. The latest-outcome marker prevents partial deletion from reopening a used grant. These are consistency checks, not cryptographic authentication of arbitrary replacement saves.
+
+The original counters remain history. A shared server shipment-opportunity helper allows only the signed second-shipment stage; the normal reserve, cross/return and typed Homeworld adapters still perform custody, legality and payment. Pending reserve or Homeworld shipment binds its actual Guild interception decision. Completing or stopping that shipment reaches the Hajr step without restoring another shipping right. Declining only the second shipment records a distinct nonphysical outcome and preserves the original counters.
+
+Being unallied is required when declaring Cunning. An alliance legally formed by a later shipment-entry effect does not revoke the played card’s remaining continuation. A real Moritani placement, next-turn second shipment, saved alliance reply and subsequent Hajr movement verify that distinction.
+
+A completed lifecycle no longer pins later funds, forces, alliances or turn order. A live lifecycle retains the exact original owner and combined-turn queue, including Advanced out-of-order Guild timing. Printed Karama cancellation and paid BG Worthless conversion both preserve this source; countering the conversion restores the original Cunning response.
+
+Truthtrance questions and fulfillment searches recognize the second shipment as the current unused opportunity. A previously fulfilled first-shipment answer remains fulfilled. A new accepted answer binds the actual second shipment and cannot be evaded by skipping it or moving first. Private completion witnesses reach only the respondent and support all four bot profiles.
+
+## Hajr and bot policy
+
+No ordinary movement follows the extra shipment. Only an unused Hajr move is available, and a Hajr move already consumed cannot create a third move. The source's exception retains the same active Guild player through the optional extra move. Native city ornithopter range still applies normally; combining this with the separate physical Ornithopter Treachery Card remains unavailable.
+
+The server supplies `hajrAvailable` independently of the displayed movement ceiling. [Ordinary card availability](../game/card-availability.ts) uses this permission during the Cunning continuation, avoiding a false second Hajr offer when a derived ceiling happens to equal one. Controls disable movement during the second-shipment step and explain the remaining Hajr allowance afterward.
+
+All four [bot profiles](../game/bots.ts) keep their existing first-shipment and movement candidates, then consider declaring Cunning at the turn's end. They use the shared second-shipment availability and may play an owned Hajr only when the explicit extra-move permission allows it. This extends legal policy; it does not certify complete Guild strategy or calibration.
+
+## Verification
+
+Nine new cases in [controls](../tests/nexus-guild-cunning-controls.test.ts) and [bots](../tests/nexus-guild-cunning-bots.test.ts) use the [genuine setup and first-turn fixture](../tests/fixture-nexus-guild-cunning.ts). Coverage includes Basic/Advanced declaration, owner/stale/privacy fences, unchanged first-turn flags, second cross-shipment and Homeworld routes, no ordinary movement, Hajr availability and all four profiles. The deliberate card alias in one consumer-only Hajr check is labeled and is never submitted to the engine.
+
+Focused verification passed **9/9 new consumer cases**. Existing shipment-budget, promise, Homeworld-control, Nexus, reference and ordinary-card compatibility passed **60/60 cases**. Type-aware lint passed. The final focused run includes immutable same-territory transport rejection in ordinary and Cunning routes, a new second-shipment Truthtrance promise, and a pending-response rendering check that excludes the internal event ID. Additional coverage includes seven pure receipt/movement cases, eleven authoritative engine cases, eight production SQLite recovery cases and two genuine nested cancellation cases: 37 new cases in total. The engine and recovery cases include competing replies, exact pending shipment custody, independent terminal markers and a later Moritani alliance. Existing native rate/route cancellation gaps and unsupported arrival combinations remain explicit; this addition does not claim to resolve them.
+
+Final `npm run check` passed types, lint and **4,136/4,136 offline cases** with no skipped tests after the response-text correction. The final production build also passed. These subsystem results do not certify complete Guild, Nexus, expansion or rules-mode play.
+
+## Isolated browser acceptance
+
+Two fresh three-human-seat rooms used actual session IDs before genuine fixture initialization and exact state/version staging. No existing game was staged or reset. In SUVRJJFK, Advanced Guild with Homeworlds shipped five forces to Arrakeen, moved them to Hagga Basin, declared Cunning, and received an actual opponent allowance. During the second-shipment step, ordinary movement was disabled and both reserve and cross/return choices remained visible despite the original shipment already being used. The player cross-shipped five forces to Carthag for three spice, played its held Hajr, moved two to Arrakeen and finished at version 11. Fifteen reserves and fourteen spice remained; only two movements had occurred in total.
+
+In T3YXXLKB, the Guild completed the same original shipment and movement. An opponent used the actual Karama cancellation button on Cunning. Version 7 retained the five forces at Hagga Basin, fifteen reserves and seventeen spice, and advanced to the next player's turn without an extra shipment. The Nexus remained spent.
+
+Desktop 1440-pixel and phone 390-pixel captures were visually inspected, including second-shipment quotes, separate skip/finish buttons, Hajr and cancellation controls. Phone views had no horizontal overflow. All six private sessions refreshed with no page errors; opponent views omitted private hand, traitors and spice and all views omitted internal grant history. The read-only restoration script is `/tmp/dune-guild-cunning-restore.cjs`. Exact staging preserved 3,580 and 3,581 other rooms respectively; the final preservation audit confirmed all 3,580 original room hashes and versions unchanged.
+
+Visual inspection found that the generic response notice exposed its internal event text. The current component replaces that text with the meaning of allowance/cancellation. The final pending-state rendering regression passed after the correction; the earlier live pending screenshots precede that small text change. The completed HTTP/session suite passed all 40 cases and added thirty isolated rooms. A subsequent read-only audit preserved all 3,580 opening room versions and state hashes; the database contains 3,612 rooms, including the two new browser rooms. All six Guild QA seats restored again at versions 11 and 7 without errors. The existing development server was reused; no hourly restart was due and no recurring automation was created.
diff --git a/docs/NEXUS_GUILD_RULES.md b/docs/NEXUS_GUILD_RULES.md
index 6b55952c..d7e00a54 100644
--- a/docs/NEXUS_GUILD_RULES.md
+++ b/docs/NEXUS_GUILD_RULES.md
@@ -56,6 +56,8 @@ An implementation needs an original payment identity, exact charged contribution
Preserve distinct settlement adapters: ordinary reserve shipment, `guildShip`, native Homeworld shipment, concealed No-Field and Junction-sponsored transport. [shipment-price.ts](../game/shipment-price.ts) already separates reserve prices, Guild transport tariffs, contribution bounds and Guild income. [guild-homeworld-shipment.ts](../game/guild-homeworld-shipment.ts) explicitly requires native Guild and should not be broadened indiscriminately for Secret Ally. [shipment-promises.ts](../game/shipment-promises.ts) also requires review: a second shipping action must not erase an earlier committed Truthtrance answer or reapply one shipment's promise to the wrong action.
-Suggested saved Cunning frame: original combined-turn event/owner/turn, the completed or declined original shipment and movement status, Hajr's remaining allowance, stage (`declaration`, `shipping`, `arrival`, `extraMove`, terminal outcome), and a bound second-shipment intent/receipt. Actual shipment custody and payment remain in their existing continuations. Do not clear a shipped flag as the sole source of the extra right, and do not use current hand membership as proof after spending the Nexus. Reconnect and uncertain responses must preserve one debit, one arrival and one consumed allowance.
+The pure [Cunning receipt module](../game/nexus-guild-cunning.ts) records the original combined-turn event, native Guild owner, turn, canonical faction roster, original shipment flag, original movement count and whether Hajr had been played. `createNexusGuildCunning` checks the unallied owner's active phase-five turn. `validateNexusGuildCunning` checks historical facts without reading current hands, resources, alliances or force custody. The engine owns the independently bound mutable lifecycle and second-shipment intent/receipt. Actual shipment custody and payment remain in their existing continuations. Do not clear a shipped flag as the sole source of the extra right, and do not use current hand membership as proof after spending the Nexus. Reconnect and uncertain responses must preserve one debit, one arrival and one consumed allowance.
-Verification should exercise first shipment followed by moved/declined ordinary movement, all three second-shipment forms, Hajr before/after and already consumed, cancellation of only the selected native advantage, paid BG/entry continuations, native Homeworld routes, private offers, stale requests and actual SQLite restoration. This audit performed source/link and bounded code review only; no runtime file or test was changed.
+`nexusGuildCunningMoves(receipt, currentHajrUsed, movesAfterSecond)` returns only the remaining Hajr extra allowance. Ending ordinary movement forgoes at least one ordinary slot, so original movement counts zero and one both permit at most one Hajr move; an original count of two permits none. A newly played Hajr supplies that same one extra slot, while losing a previously recorded Hajr or claiming more moves than that allowance rejects. This bounded helper excludes the separate physical Ornithopter-card composition; it does not reset or mutate the original movement counter. Its [pure tests](../tests/nexus-guild-cunning.test.ts) cover declined and used first actions, Hajr before and after, spent allowances, native ownership, exact signatures, JSON restoration and private-field getter traps.
+
+Integrated verification should exercise first shipment followed by moved/declined ordinary movement, all three second-shipment forms, Hajr before/after and already consumed, cancellation of only the selected native advantage, paid BG/entry continuations, native Homeworld routes, private offers, stale requests and actual SQLite restoration. Passing the pure receipt tests does not establish those engine or persistence behaviors.
diff --git a/game/bots.ts b/game/bots.ts
index bd4a43e3..ef338a3a 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -1,3 +1,4 @@
+import { nexusGuildCunningAction, nexusGuildCunningActive, nexusGuildHajrAction, nexusGuildMovementAvailable, nexusGuildShipmentAvailable, nexusGuildSkipShipmentAction } from './nexus-guild-cunning-options';
import { nexusMoritaniBotActions } from './nexus-moritani-options';
import { choamPowerAction, choamPowerBotPlay } from './choam-power-options';
import { nexusCardBotActions } from './nexus-card-options';
@@ -2816,7 +2817,11 @@ function policyActions(g: GameView): Action[] {
)
return [choamPowerAction(g, kulon)!];
if (g.active !== me.id) return [];
- const cardMoves = ornithopterMoves(g);
+ const shipmentAvailable = nexusGuildShipmentAvailable(g);
+ const movementAvailable = nexusGuildMovementAvailable(g);
+ const hajr = nexusGuildHajrAction(g);
+ if (hajr) return [hajr];
+ const cardMoves = nexusGuildCunningActive(g) ? [] : ornithopterMoves(g);
if (
cardMoves.length &&
(g.ornithopter?.active || me.shipped || me.reserves === 0)
@@ -2913,7 +2918,7 @@ function policyActions(g: GameView): Action[] {
});
}
}
- if (!me.shipped && me.reserves > 0)
+ if (shipmentAvailable && me.reserves > 0)
for (const to of destinations(g, g.advanced && me.faction === 'fremen')
.filter(
(to) =>
@@ -2961,7 +2966,7 @@ function policyActions(g: GameView): Action[] {
const guildTransport =
me.faction === 'guild' ||
g.players.some((p) => p.faction === 'guild' && p.id === me.ally);
- if (!me.shipped && guildTransport)
+ if (shipmentAvailable && guildTransport)
for (const to of targets
.filter((to) => botEntryAllowed(g, me, to.t, to.s, 'guildShip'))
.slice(0, 16)) {
@@ -3027,7 +3032,7 @@ function policyActions(g: GameView): Action[] {
return { from, amount, elite };
})
.filter((source) => source.amount > 0);
- if ((me.moved ?? 0) < (me.movesAllowed ?? 1))
+ if (movementAvailable)
for (const to of targets
.filter(
(to) =>
@@ -3051,6 +3056,15 @@ function policyActions(g: GameView): Action[] {
if (reveal && !actions.length) actions.push(reveal);
if (me.shipped || !actions.some((action) => action.type === 'ship' || action.type === 'homeworldShip'))
actions.unshift(...emperorHomeworldMoveActions(g));
+ const activeCunning = nexusGuildCunningActive(g);
+ if (activeCunning?.stage === 'secondShipment' && activeCunning.hajrAvailable &&
+ me.hand?.some(card => card.effect === 'hajr') &&
+ !actions.some(action => ['ship', 'guildShip', 'homeworldShip', 'guildHomeworldShip'].includes(action.type))) {
+ const skip = nexusGuildSkipShipmentAction(g, activeCunning.event);
+ if (skip) actions.push(skip);
+ }
+ const cunning = g.nexusGuildCunning?.offer && nexusGuildCunningAction(g, g.nexusGuildCunning.offer.event);
+ if (cunning && (me.reserves > 0 || Object.values(me.forces).some(n => n > 0))) actions.push(cunning);
actions.push({ type: 'endMovement' });
return g.ornithopter?.active
? actions.filter((action) => action.type !== 'move')
@@ -3353,6 +3367,7 @@ export function botActions(g: GameView): Action[] {
const intelligence = tupileIntelligenceActions(g);
if (intelligence.length) return intelligence;
const actions = [...junctionTransportActions(g, rank(g)), ...policyActions(g)].flatMap((action) => {
+ if (action.type === 'move' && nexusGuildCunningActive(g) && !nexusGuildMovementAvailable(g)) return [];
const sourced = withNativeShipmentSources(g, action);
return sourced && botHomeworldShipmentPaymentAllowed(g, sourced) && !homeworldRevivalActionBlock(g, sourced) ? [sourced] : [];
});
@@ -3362,7 +3377,7 @@ export function botActions(g: GameView): Action[] {
if (
g.phase !== 5 ||
g.active !== me.id ||
- me.shipped ||
+ !nexusGuildShipmentAvailable(g) ||
g.truthtrance ||
g.response ||
g.decision ||
diff --git a/game/card-availability.ts b/game/card-availability.ts
index d8d7d115..d82cf6d1 100644
--- a/game/card-availability.ts
+++ b/game/card-availability.ts
@@ -24,6 +24,7 @@ export type OrdinaryCardState = {
phase: number;
active: string | null;
me: string;
+ nexusGuildCunning?: { active: { hajrAvailable: boolean } | null } | null;
truthtrance?: { stage: string } | null;
phaseOpening?: { passed: readonly string[] } | null;
response?: object | null;
@@ -101,7 +102,7 @@ export function ordinaryCardAvailability(
if (
state.phase !== 5 ||
state.active !== state.me ||
- own.movesAllowed !== 1
+ (state.nexusGuildCunning?.active ? !state.nexusGuildCunning.active.hajrAvailable : own.movesAllowed !== 1)
)
return blocked('Use Hajr during your movement.');
break;
diff --git a/game/engine.ts b/game/engine.ts
index 7e0c9ead..609a5ed3 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -1,3 +1,5 @@
+import { shipmentAvailable } from './shipment-opportunity';
+import { createNexusGuildCunning, validateNexusGuildCunning, nexusGuildCunningMoves, type NexusGuildCunningReceipt } from './nexus-guild-cunning';
import { createNexusRichese, validateNexusRichese, quoteNexusRicheseShipment, type NexusRicheseReceipt } from './nexus-richese';
import { createNexusInspection, allowNexusInspection, answerNexusInspection, reopenNexusInspection, cancelNexusInspection, reopenNexusNative, answerNexusNative, validateNexusInspection, committedPlanElements, type NexusInspection, type BattleInspectionContext } from './battle-inspections';
import { beginNexusTraitorExchange, finishNexusTraitorExchange, validateNexusTraitorExchange, validateNexusTraitorHistory, validateNexusTraitorSnapshot, type NexusTraitorExchange, type NexusTraitorSnapshot } from './nexus-traitor-exchange';
@@ -801,6 +803,7 @@ export type ResponseWindow = {
| 'advisorFlip'
| 'nexusAdvisorFlip'
| 'nexusSardaukar'
+ | 'nexusGuildCunning'
| 'bgCharity'
| 'choamCharity'
| 'choamInflation'
@@ -835,6 +838,7 @@ type KaramaUse =
| { kind: 'purchase' }
| { kind: 'auctionPayment' };
type PendingHomeworldShipment = HomeworldShipmentIntent & {
+ guildNexusEvent?: string;
/** Absent in older world-to-world declarations. */
route?: 'arrakis';
event: string;
@@ -846,6 +850,7 @@ type PendingHomeworldShipment = HomeworldShipmentIntent & {
pools: ReturnType
['sources'] | ReturnType['boardSources'];
};
type PendingShipment = {
+ guildNexusEvent?: string;
nexusEvent?: string;
homeworldSources?: NativeReserveSelections;
source?: 'ambassador';
@@ -1284,6 +1289,8 @@ export type Game = {
nexusSardaukarLast?: {event: string; stage: 'pending' | 'active' | 'canceled'};
nexusChoamHistory?: {receipt: NexusChoamReceipt; stage: 'pending' | 'canceled' | 'complete' | 'fizzled'; frame: string; parent: string; signature: string}[];
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'};
nexusRicheseHistory?: {receipt:NexusRicheseReceipt;stage:'pending'|'shipped'|'stopped';frame:string;halfRate:boolean;signature:string}[];
nexusRicheseLast?: {event:string;stage:'pending'|'shipped'|'stopped'};
nexusMoritaniLocations?: Record;
@@ -6428,6 +6435,7 @@ function karamaOpportunitySignature(
active: g.active,
recipient: use.recipient,
shipped: getPlayer(g, use.recipient).shipped,
+ ...(currentGuildCunning(g,use.recipient) ? {guildCunning:currentGuildCunning(g,use.recipient)} : {}),
rate: g.karamaShipping ?? null,
movementRemaining: g.movementRemaining ?? null,
}
@@ -6758,6 +6766,7 @@ function validateKaramaUse(
use.response,
spendingCard ? { player: p.id, card: spendingCard.id } : undefined,
);
+ if (use.response.kind === 'nexusGuildCunning') validateGuildCunningResponse(g,use.response);
currentChoamWorthlessCancellationQuote(g, use.response);
currentPlacementCancellationQuote(g, use.response);
if (use.response.kind === 'ecazCollection')
@@ -6770,7 +6779,7 @@ function validateKaramaUse(
g.status === 'playing' &&
g.phase === 5 &&
g.active === recipient.id &&
- !recipient.shipped &&
+ shipmentAvailable(g,recipient) &&
!g.karamaShipping,
'Choose the active player before their unused Karama shipment.',
);
@@ -7315,6 +7324,8 @@ function forceGroup(p: Player, action: Action) {
};
}
function movesAllowed(g: Game, p: Player) {
+ const cunning = currentGuildCunning(g,p.id);
+ if (cunning) return p.moved + (cunning.stage === 'extraMove' ? nexusRule(() => nexusGuildCunningMoves(cunning.receipt,g.hajr.includes(p.id),cunning.movesAfter)) : 0);
const flight = g.ornithopter;
return flight?.player === p.id && flight.turn === g.turn
? flight.startingMove + (flight.mode === 'twoGroups' ? 2 : 1)
@@ -7387,6 +7398,7 @@ function ornithopterBlock(
return 'Resolve the current interaction before declaring a movement.';
if (g.ornithopter)
return 'Finish the already played Ornithopter movement first.';
+ if (currentGuildCunning(g,p.id)) return 'Guild Cunning combined with the physical Ornithopter card awaits its timing ruling.';
if (p.moved || g.hajr.includes(p.id))
return 'Ornithopter combined with prior moves or Hajr awaits its timing ruling.';
if (
@@ -7827,6 +7839,8 @@ function completeMove(g: Game, move: MovementOrder) {
finishShipmentPromises(g, p, null);
p.shipped = true;
p.moved++;
+ const cunning = currentGuildCunning(g,id);
+ if (cunning) { requireRule(cunning.stage === 'extraMove', 'Guild Cunning permits no movement before the second shipment.'); cunning.movesAfter++; saveGuildCunning(g,cunning); }
const completed: CompletedMovement = {
player: id,
origin,
@@ -7862,6 +7876,8 @@ function finishMovedGroup(g: Game, move: CompletedMovement) {
/** A played movement card is already retired before queue advancement. */
function finishMovementTurn(g: Game, id: string) {
const p = getPlayer(g, id);
+ const cunning = currentGuildCunning(g,id);
+ if (cunning) { requireRule(cunning.stage !== 'pending' && cunning.shipment?.stage !== 'pending', 'Finish the Guild Cunning response or shipment first.'); cunning.stage = 'completed'; saveGuildCunning(g,cunning); }
g.karamaShipping = null;
g.movementRemaining ??= g.order.slice(g.order.indexOf(id));
const shared = p.ally
@@ -12102,6 +12118,7 @@ function marketGholaIntegrity(g: Game) {
nexusChoamIntegrity(g);
nexusMoritaniIntegrity(g);
nexusRicheseIntegrity(g);
+ nexusGuildCunningIntegrity(g);
traitorDeclarationIntegrity(g);
nexusInspectionIntegrity(g);
homeworldHistoryIntegrity(g);
@@ -12468,7 +12485,7 @@ function shipmentPromiseIntegrity(g: Game) {
g.status === 'playing' &&
g.phase === 5 &&
g.active === promise.player &&
- !getPlayer(g, promise.player).shipped,
+ shipmentAvailable(g,getPlayer(g,promise.player)),
'The saved shipment promise does not belong to this unused Basic shipment opportunity.',
);
}
@@ -12487,7 +12504,7 @@ function findShipmentCompletion(
state.status !== 'playing' ||
state.phase !== 5 ||
state.active !== owner.id ||
- owner.shipped
+ !shipmentAvailable(state,owner)
)
return null;
if (!promises.some((p) => p.answer))
@@ -14509,6 +14526,7 @@ function finishResponse(g: Game, canceled: boolean) {
if (response.kind === 'nexusPrescience') validateNexusInspectionResponse(g, response);
if (response.kind === 'nexusAdvisorFlip') validateNexusAdvisorResponse(g, response);
if (response.kind === 'nexusSardaukar') validateNexusSardaukarResponse(g, response);
+ if (response.kind === 'nexusGuildCunning') validateGuildCunningResponse(g,response);
if (response.kind === 'moritaniPlacement') {
// Both outcomes need the same physical inventory and declared source.
// This detached denial quote is only validation here; allowance still
@@ -14548,6 +14566,7 @@ function finishResponse(g: Game, canceled: boolean) {
? moritaniAllianceCancellationQuote(g, response)
: null;
g.response = null;
+ if (response.kind === 'nexusGuildCunning') { settleGuildCunning(g,response,canceled); return; }
if (response.kind === 'nexusAdvisorFlip') {
finishNexusAdvisors(g, canceled);
return;
@@ -15566,11 +15585,12 @@ function homeworldShipmentEvent(g: Game, p: Player, route?: 'arrakis'): string {
g.storm, p.forces, p.elites?.forces ?? {}, g.mobileStronghold?.location ?? null]);
return JSON.stringify([g.turn, g.phase, g.active, p.id, p.shipped,
homeworldContext(g), g.homeworlds?.custody,
- g.players.map((seat) => [seat.id, seat.ally])]);
+ g.players.map((seat) => [seat.id, seat.ally]),
+ ...(currentGuildCunning(g,p.id) ? [currentGuildCunning(g,p.id)!.receipt.event] : [])]);
}
function homeworldShipmentBlock(g: Game, p: Player, checkPromises = true): string | null {
if (!g.homeworlds?.custody) return 'Homeworld shipment requires the Homeworld module.';
- if (g.status !== 'playing' || g.phase !== 5 || g.active !== p.id || p.shipped)
+ if (g.status !== 'playing' || g.phase !== 5 || g.active !== p.id || !shipmentAvailable(g,p))
return 'Use your unused shipment during your own Shipment and Movement turn.';
if (g.truthtrance || g.response || g.decision || g.phaseOpening ||
g.pendingNullentropy || g.pendingTreacheryDiscard || g.pendingShipment ||
@@ -15697,8 +15717,9 @@ function homeworldShipmentQuote(g: Game, intent: HomeworldShipmentIntent & {rout
sourceNames: quote.sources.map((s) => combatLocationName(g, s.homeworld))};
}
function validateHomeworldShipment(g: Game, shipment: PendingHomeworldShipment) {
+ 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 && !p.shipped &&
+ requireRule(g.status === 'playing' && g.phase === 5 && g.active === p.id && shipmentAvailable(g,p) &&
shipment.turn === g.turn && (shipment.route === undefined || shipment.route === 'arrakis') &&
shipment.event === homeworldShipmentEvent(g, p, shipment.route),
'This Homeworld declaration no longer matches its unused shipment and physical custody.');
@@ -15732,6 +15753,7 @@ function homeworldShipmentIntegrity(g: Game) {
}
function commitHomeworldShipment(g: Game, shipment: PendingHomeworldShipment) {
const quote = validateHomeworldShipment(g, shipment);
+ if (shipment.guildNexusEvent) finishGuildCunningShipment(g,shipment.player,'homeworld',shipment);
const p = getPlayer(g, shipment.player);
payWithAlly(g, p, quote.cost, shipment.allyPayment);
g.homeworlds!.custody = quote.state;
@@ -15761,9 +15783,10 @@ function commitHomeworldShipment(g: Game, shipment: PendingHomeworldShipment) {
}
function declareHomeworldShipment(g: Game, p: Player, intent: HomeworldShipmentIntent & {route?: 'arrakis'}, allyPayment?: unknown) {
const quote = homeworldShipmentQuote(g, intent);
- const shipment: PendingHomeworldShipment = {...intent, sources: structuredClone(intent.sources),
+ 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,
cost: quote.cost, allyPayment: contribution(g, p, quote.cost, allyPayment), pools: quote.sources};
+ bindGuildCunningShipment(g,p,'homeworld',shipment);
const guild = byFaction(g, 'guild');
if (g.advanced && guild && !guild.specialKaramaUsed) {
g.pendingHomeworldShipment = shipment;
@@ -15773,6 +15796,7 @@ function declareHomeworldShipment(g: Game, p: Player, intent: HomeworldShipmentI
} else commitHomeworldShipment(g, shipment);
}
function validatePhysicalShipment(g: Game, shipment: PendingShipment) {
+ if (shipment.guildNexusEvent) validateGuildCunningShipment(g,shipment.player,shipment.guildNexusEvent,'reserve',shipment);
if (shipment.nexusEvent !== undefined) currentNexusRicheseShipment(g,shipment);
requireRule(
!(shipment.noField || shipment.alliedNoField) ||
@@ -15790,7 +15814,7 @@ function validatePhysicalShipment(g: Game, shipment: PendingShipment) {
g.status === 'playing' &&
g.phase === 5 &&
g.active === p.id &&
- !p.shipped &&
+ shipmentAvailable(g,p) &&
(shipment.turn === undefined || shipment.turn === g.turn),
'This shipment no longer belongs to the current unused shipment opportunity.',
);
@@ -15922,6 +15946,7 @@ function commitShipment(g: Game, shipment: PendingShipment) {
}
validatePhysicalShipment(g, shipment);
finishNexusRicheseShipment(g,shipment,'shipped');
+ if (shipment.guildNexusEvent) finishGuildCunningShipment(g,shipment.player,'reserve',shipment);
finishShipmentPromises(
g,
getPlayer(g, shipment.player),
@@ -16132,6 +16157,149 @@ function choamWorthlessBlocked(g: Game, card: string) {
g.choamWorthlessBlocked.cards.includes(card)
);
}
+function currentGuildCunning(g: Game, owner?: string) {
+ const last = g.nexusGuildCunningHistory?.at(-1);
+ return last && last.receipt.turn === g.turn && (!owner || last.receipt.owner === owner) &&
+ ['pending','secondShipment','extraMove'].includes(last.stage) ? last : null;
+}
+function guildCunningParent(g: Game) {
+ return JSON.stringify([g.active,g.order,g.movementRemaining,g.guildTimingGranted,g.guildTimingLocked,g.saphoMovementLast ?? null]);
+}
+function guildCunningSignature(record: NonNullable[number]) {
+ return JSON.stringify([record.receipt.signature,record.stage,record.parent,record.movesAfter,record.shipment ?? null]);
+}
+function saveGuildCunning(g: Game, record: NonNullable[number]) {
+ record.signature = guildCunningSignature(record);
+ g.nexusGuildCunningLast = {event:record.receipt.event,stage:record.stage};
+}
+function projectedNexusGuildCunning(g: Game, owner: string) {
+ const p = g.players.find(p => p.id === owner);
+ if (!p || p.faction !== 'guild') return null;
+ const record = currentGuildCunning(g,owner);
+ if (record) return {offer:null,active:{event:record.receipt.event,stage:record.stage as 'pending'|'secondShipment'|'extraMove',
+ movesLeft:record.stage === 'extraMove' ? nexusRule(() => nexusGuildCunningMoves(record.receipt,g.hajr.includes(owner),record.movesAfter)) : 0,
+ hajrAvailable:record.stage !== 'pending' && !g.hajr.includes(owner)}};
+ if (g.nexusCards?.cards?.hands[owner] !== 'guild') return null;
+ let blocked: string | null = null;
+ if (p.ally) blocked = 'Guild Cunning requires an unallied native Guild player.';
+ else if (g.status !== 'playing' || g.phase !== 5 || g.active !== owner)
+ blocked = 'Use Cunning when finishing your own Shipment and Movement turn.';
+ else if (g.response || g.decision || g.truthtrance || g.phaseOpening || g.pendingKarama || g.pendingTreacheryDiscard ||
+ g.pendingNullentropy || g.pendingShipment || g.pendingHomeworldShipment || g.nexusTraitorPending || g.nexusCards.phase?.stage === 'drawing')
+ blocked = 'Finish the current interaction before declaring a second shipment.';
+ else if (g.ornithopter || p.moved > (g.hajr.includes(owner) ? 2 : 1))
+ blocked = 'Cunning combined with the physical Ornithopter movement card awaits its timing ruling.';
+ else if (g.nexusGuildCunningHistory?.some(r => r.receipt.turn === g.turn && r.receipt.owner === owner))
+ blocked = 'This turn’s Guild Cunning opportunity is already used.';
+ return {offer:{event:JSON.stringify(['nexusGuildCunning',g.turn,owner]),blocked},active:null};
+}
+function declareGuildCunning(g: Game, p: Player, event: unknown) {
+ const offer = projectedNexusGuildCunning(g,p.id)?.offer;
+ requireRule(offer && !offer.blocked && event === offer.event,offer?.blocked ?? 'This Guild Cunning opportunity is stale.');
+ const receipt = nexusRule(() => createNexusGuildCunning(g,p.id,{shipped:p.shipped,moved:p.moved,hajrUsed:g.hajr.includes(p.id)}));
+ const record = {receipt,stage:'pending' as const,parent:guildCunningParent(g),movesAfter:0,signature:''};
+ g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,p.id,g.players));
+ (g.nexusGuildCunningHistory ??= []).push(record); saveGuildCunning(g,record);
+ g.karamaShipping = null;
+ g.response = {kind:'nexusGuildCunning',owner:p.id,intent:receipt.event,passed:[]};
+ log(g,`${p.name} finished the ordinary shipment and movement opportunity and played Guild Cunning for an immediate second paid shipment. The Nexus is spent; Hajr is required for any further movement.`);
+}
+function validateGuildCunningResponse(g: Game, response: ResponseWindow) {
+ const record = currentGuildCunning(g,response.owner);
+ requireRule(record?.stage === 'pending' && response.kind === 'nexusGuildCunning' && response.intent === record.receipt.event &&
+ record.parent === guildCunningParent(g), 'Guild Cunning has lost its original combined-turn response.');
+ return record;
+}
+function settleGuildCunning(g: Game, response: ResponseWindow, canceled: boolean) {
+ const record = validateGuildCunningResponse(g,response);
+ record.stage = canceled ? 'canceled' : 'secondShipment'; saveGuildCunning(g,record);
+ log(g,canceled ? 'Karama prevented Guild Cunning. The first shipment and movement remain completed and the Nexus remains spent.' :
+ 'Guild Cunning permits one immediate paid shipment using normal Guild routes. Ordinary movement is finished; an unused Hajr move remains possible afterward.',
+ canceled ? undefined : {faction:'guild',name:'Cunning: second shipment'});
+ if (canceled) finishMovementTurn(g,record.receipt.owner);
+}
+function bindGuildCunningShipment(g: Game, p: Player, kind: 'reserve'|'guild'|'homeworld', frame: unknown) {
+ const record = currentGuildCunning(g,p.id);
+ if (!record) return;
+ requireRule(record.stage === 'secondShipment' && !record.shipment, 'Guild Cunning already has its second shipment.');
+ record.shipment = {kind,frame:JSON.stringify(frame),stage:'pending'}; saveGuildCunning(g,record);
+}
+function validateGuildCunningShipment(g: Game, owner: string, event: string, kind: 'reserve'|'guild'|'homeworld', frame: unknown) {
+ const record = currentGuildCunning(g,owner);
+ requireRule(record?.stage === 'secondShipment' && record.receipt.event === event && record.shipment?.stage === 'pending' &&
+ record.shipment.kind === kind && record.shipment.frame === JSON.stringify(frame), 'Guild Cunning has lost its original second shipment.');
+ return record;
+}
+function finishGuildCunningShipment(g: Game, owner: string, kind: 'reserve'|'guild'|'homeworld', frame: unknown, outcome:'complete'|'stopped' = 'complete') {
+ const record = currentGuildCunning(g,owner);
+ if (!record) return;
+ validateGuildCunningShipment(g,owner,record.receipt.event,kind,frame);
+ record.shipment!.stage = outcome; record.stage = 'extraMove'; saveGuildCunning(g,record);
+}
+function nexusGuildCunningIntegrity(g: Game) {
+ const continuation = g.pendingTreacheryDiscard?.continuation;
+ const contexts = [g,g.pendingExchange,g.pendingNullentropy?.resume,g.pendingRicheseGift?.resume,g.pendingRichesePurchaseIncome?.resume,g.summonedWorm?.resume,
+ continuation && 'resume' in continuation ? continuation.resume : null];
+ const responses = contexts.flatMap(c => {
+ if (!c) return [];
+ const karama = 'pendingKarama' in c ? c.pendingKarama as Game['pendingKarama'] : null;
+ return [c.response,karama?.use?.kind === 'cancel' ? karama.use.response : null];
+ }).filter(r => r?.kind === 'nexusGuildCunning');
+ const history = g.nexusGuildCunningHistory;
+ if (history === undefined) {
+ requireRule(!g.nexusGuildCunningLast && !responses.length && !g.pendingShipment?.guildNexusEvent && !g.pendingHomeworldShipment?.guildNexusEvent,
+ 'Guild Cunning has lost its saved second-shipment history.'); return;
+ }
+ requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0,'Guild Cunning requires its original Nexus module and history.');
+ const events = new Set();
+ for (const [index,record] of history.entries()) {
+ requireRule(record && typeof record === 'object' && Object.keys(record).every(k => ['receipt','stage','parent','movesAfter','shipment','signature'].includes(k)) &&
+ ['pending','secondShipment','extraMove','completed','canceled'].includes(record.stage) && typeof record.parent === 'string' &&
+ record.signature === guildCunningSignature(record) && !events.has(record.receipt?.event) &&
+ Number.isSafeInteger(record.movesAfter) && record.movesAfter >= 0 && record.movesAfter <= 1 &&
+ (['completed','canceled'].includes(record.stage) || index === history.length - 1),'Guild Cunning has a changed or duplicated allowance record.');
+ nexusRule(() => validateNexusGuildCunning(g,record.receipt));
+ if (record.shipment?.kind === 'declined') requireRule(Object.keys(record.shipment).sort().join(',') === 'kind,stage' && record.shipment.stage === 'declined' && ['extraMove','completed'].includes(record.stage), 'Guild Cunning has changed its declined second shipment.');
+ else if (record.shipment) requireRule(Object.keys(record.shipment).sort().join(',') === 'frame,kind,stage' &&
+ ['reserve','guild','homeworld'].includes(record.shipment.kind) && typeof record.shipment.frame === 'string' &&
+ ['pending','complete','stopped'].includes(record.shipment.stage) &&
+ (record.shipment.stage === 'pending' ? record.stage === 'secondShipment' : ['extraMove','completed'].includes(record.stage)),
+ 'Guild Cunning has changed its second shipment outcome.');
+ requireRule(record.stage !== 'extraMove' || (record.shipment && record.shipment.stage !== 'pending'), 'Guild Cunning has lost its completed second shipment.');
+ requireRule(record.stage !== 'pending' || (!record.shipment && !record.movesAfter), 'Guild Cunning changed before its response completed.');
+ requireRule(!record.movesAfter || record.stage === 'extraMove' || record.stage === 'completed','Guild Cunning has moved before its second shipment.');
+ events.add(record.receipt.event);
+ }
+ const last = history.at(-1)!;
+ requireRule(JSON.stringify(g.nexusGuildCunningLast) === JSON.stringify({event:last.receipt.event,stage:last.stage}), 'Guild Cunning has lost its latest allowance marker.');
+ const active = currentGuildCunning(g);
+ requireRule(['completed','canceled'].includes(last.stage) || active, 'Guild Cunning has left its original turn unfinished.');
+ requireRule((active?.stage === 'pending') === !!responses.length, 'Guild Cunning has lost or reopened its native response.');
+ if (!active) {
+ requireRule(!g.pendingShipment?.guildNexusEvent && !g.pendingHomeworldShipment?.guildNexusEvent,'Guild Cunning has reopened a completed shipment.'); return;
+ }
+ const owner = getPlayer(g,active.receipt.owner);
+ requireRule(g.status === 'playing' && g.phase === 5 && g.active === owner.id &&
+ active.parent === guildCunningParent(g) && owner.moved === active.receipt.before.moved + active.movesAfter &&
+ owner.shipped === (active.stage === 'extraMove' && (active.shipment?.kind !== 'declined' || active.movesAfter > 0) ? true : active.receipt.before.shipped) && g.nexusCards.cards.discard.includes('guild'),
+ 'Guild Cunning changed its original active player, queue, shipment or movement allowance.');
+ nexusRule(() => nexusGuildCunningMoves(active.receipt,g.hajr.includes(owner.id),active.movesAfter));
+ for (const response of responses) validateGuildCunningResponse(g,response!);
+ if (active.shipment?.stage === 'pending') {
+ if (active.shipment.kind === 'reserve') {
+ requireRule(g.pendingShipment?.guildNexusEvent === active.receipt.event, 'Guild Cunning lost its pending reserve shipment.');
+ validateGuildCunningShipment(g,owner.id,active.receipt.event,'reserve',g.pendingShipment);
+ const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'guildShipment');
+ requireRule(decisions.length > 0,'Guild Cunning lost its shipment interception decision.');
+ for (const d of decisions) validateGuildShipmentDecision(g,d);
+ } else if (active.shipment.kind === 'homeworld') {
+ requireRule(g.pendingHomeworldShipment?.guildNexusEvent === active.receipt.event,'Guild Cunning lost its pending Homeworld shipment.');
+ validateGuildCunningShipment(g,owner.id,active.receipt.event,'homeworld',g.pendingHomeworldShipment);
+ homeworldShipmentIntegrity(g);
+ } else throw new RuleError('Guild Cunning retained an unfinished immediate cross-shipment.');
+ } else requireRule(!g.pendingShipment?.guildNexusEvent && !g.pendingHomeworldShipment?.guildNexusEvent,
+ 'Guild Cunning has a shipment without its original pending record.');
+}
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;
@@ -17125,6 +17293,7 @@ export function executeSpecialKaramaIntent(
const shipper = getPlayer(g, intent.target);
discard(g, p, card.id);
p.specialKaramaUsed = true;
+ if (g.pendingHomeworldShipment?.guildNexusEvent) finishGuildCunningShipment(g,shipper.id,'homeworld',g.pendingHomeworldShipment,'stopped');
shipper.shipped = true;
g.pendingHomeworldShipment = null;
g.decision = null;
@@ -17145,6 +17314,7 @@ export function executeSpecialKaramaIntent(
finishAmbassador(g);
return;
}
+ if (g.pendingShipment?.guildNexusEvent) finishGuildCunningShipment(g,shipper.id,'reserve',g.pendingShipment,'stopped');
if (g.pendingShipment) finishNexusRicheseShipment(g,g.pendingShipment,'stopped');
shipper.shipped = true;
// Provisional canceled-shipment settlement pending a primary-source
@@ -19969,7 +20139,7 @@ function applyActionInner(
}
if (t === 'ship') {
requireRule(
- g.phase === 5 && g.active === id && !p.shipped,
+ g.phase === 5 && g.active === id && shipmentAvailable(g,p),
'You may ship once, before your movement.',
);
let nexusEvent: string | undefined;
@@ -20048,6 +20218,7 @@ function applyActionInner(
);
const allyPayment = contribution(g, p, cost, action.allyPayment);
const shipment: PendingShipment = {
+ ...(currentGuildCunning(g,id) ? {guildNexusEvent:currentGuildCunning(g,id)!.receipt.event} : {}),
...(nexusEvent ? {nexusEvent} : {}),
turn: g.turn,
player: id,
@@ -20067,6 +20238,7 @@ function applyActionInner(
...(noField ? { noField } : {}),
};
if (nexusEvent) recordNexusRicheseShipment(g,shipment);
+ bindGuildCunningShipment(g,p,'reserve',shipment);
if (shipment.noField) {
checkShipmentIncomeRounding(g, p, cost, allyPayment);
g.pendingShipment = shipment;
@@ -20109,7 +20281,7 @@ function applyActionInner(
requireRule(
g.phase === 5 &&
g.active === id &&
- !p.shipped &&
+ shipmentAvailable(g,p) &&
(p.faction === 'guild' || byFaction(g, 'guild')?.id === p.ally),
'Guild shipment is not available.',
);
@@ -20144,6 +20316,7 @@ function applyActionInner(
);
const to = stringField(action.territory ?? 'reserves');
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',
'Only the Guild may ship forces back to reserves.',
@@ -20172,6 +20345,8 @@ 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};
+ bindGuildCunningShipment(g,p,'guild',cunningFrame);
payWithAlly(g, p, cost, allyPayment);
const guild = byFaction(g, 'guild');
const guildPayment = guildShipmentIncome({
@@ -20207,6 +20382,7 @@ function applyActionInner(
p,
fromReserves ? { territory: to, amount: n } : null,
);
+ finishGuildCunningShipment(g,id,'guild',cunningFrame);
p.shipped = true;
g.karamaShipping = null;
log(g, `${p.name} used Guild transport for ${n} forces.`);
@@ -20441,12 +20617,24 @@ function applyActionInner(
} else offerChoamMovement(g, move);
return g;
}
+ if (t === 'nexusGuildSkipShipment') {
+ const record = currentGuildCunning(g,id);
+ requireRule(g.status === 'playing' && g.phase === 5 && g.active === id &&
+ record?.stage === 'secondShipment' && !record.shipment && action.event === record.receipt.event &&
+ Object.keys(action).every(k => ['type','event'].includes(k)), 'Only the current unused Guild Cunning shipment may be declined.');
+ finishShipmentPromises(g,p,null);
+ record.shipment = {kind:'declined',stage:'declined'};
+ record.stage = 'extraMove'; saveGuildCunning(g,record);
+ log(g,`${p.name} declined the second Guild shipment. No forces or spice moved; only an unused Hajr movement remains available.`);
+ return g;
+ }
if (t === 'endMovement') {
finishShipmentPromises(g, p, null);
requireRule(
g.phase === 5 && g.active === id,
'Wait for your movement turn.',
);
+ if (action.nexus !== undefined) { declareGuildCunning(g,p,action.nexus); return g; }
if (g.ornithopter?.player === id) finishOrnithopter(g, 'end');
else finishMovementTurn(g, id);
return g;
@@ -20717,7 +20905,7 @@ function applyActionInner(
) {
const recipient = getPlayer(g, stringField(action.target ?? id));
requireRule(
- g.phase === 5 && g.active === recipient.id && !recipient.shipped,
+ g.phase === 5 && g.active === recipient.id && shipmentAvailable(g,recipient),
'Choose the active player before their shipment.',
);
requireRule(
@@ -20761,6 +20949,7 @@ function applyActionInner(
phase: g.phase,
active: g.active,
me: id,
+ nexusGuildCunning: projectedNexusGuildCunning(g,id),
truthtrance: g.truthtrance,
phaseOpening: g.phaseOpening,
response: g.response,
@@ -21139,6 +21328,7 @@ export function viewGame(state: Game, id: string) {
nexusCards: projectedNexusCards(g, id),
nexusMoritani: nexusMoritaniOffer(g,id),
nexusRichese: nexusRicheseOffer(g,id),
+ nexusGuildCunning: projectedNexusGuildCunning(g,id),
nexusTraitors: projectedNexusTraitors(g, id),
nexusTleilaxu: projectedNexusTleilaxu(g, id),
nexusSuboids: projectedNexusSuboids(g, id),
diff --git a/game/guild-homeworld-shipment-options.ts b/game/guild-homeworld-shipment-options.ts
index 23665dac..402d5fd4 100644
--- a/game/guild-homeworld-shipment-options.ts
+++ b/game/guild-homeworld-shipment-options.ts
@@ -1,3 +1,4 @@
+import { nexusGuildShipmentAvailable } from './nexus-guild-cunning-options';
import type { Action, GameView } from './engine';
import { HOMEWORLD_CARDS } from './homeworld-cards';
import { gameTerritories, MOBILE_LOCATION, splitLocation } from './board';
@@ -43,7 +44,7 @@ export function guildHomeworldShipmentChoice(
view.status !== 'playing' ||
view.phase !== 5 ||
view.active !== me.id ||
- me.shipped
+ !nexusGuildShipmentAvailable(view)
) {
result.blocked = 'Use Guild transport during your unused shipment turn.';
return result;
@@ -148,7 +149,7 @@ export function guildHomeworldShipmentActions(
me.faction !== 'guild' ||
!view.guildHomeworldShipment ||
view.guildHomeworldShipment.blocked ||
- me.shipped ||
+ !nexusGuildShipmentAvailable(view) ||
view.phase !== 5 ||
view.active !== me.id
)
diff --git a/game/homeworld-shipment-options.ts b/game/homeworld-shipment-options.ts
index de11ef7e..898c61df 100644
--- a/game/homeworld-shipment-options.ts
+++ b/game/homeworld-shipment-options.ts
@@ -1,3 +1,4 @@
+import { nexusGuildShipmentAvailable } from './nexus-guild-cunning-options';
import type { Action, GameView } from './engine';
import { HOMEWORLD_CARDS } from './homeworld-cards';
import {
@@ -50,7 +51,7 @@ export function homeworldShipmentChoice(
view.status !== 'playing' ||
view.phase !== 5 ||
view.active !== view.me ||
- me.shipped
+ !nexusGuildShipmentAvailable(view)
) {
result.blocked = 'Use Homeworld shipment during your unused shipment turn.';
return result;
@@ -131,7 +132,7 @@ export function homeworldShipmentActions(
view.homeworldShipment.blocked ||
view.phase !== 5 ||
view.active !== me.id ||
- me.shipped
+ !nexusGuildShipmentAvailable(view)
)
return [];
const difficulty = Math.max(0, Math.min(3, level));
diff --git a/game/karama-context.ts b/game/karama-context.ts
index 71755a4a..50c132f2 100644
--- a/game/karama-context.ts
+++ b/game/karama-context.ts
@@ -84,6 +84,7 @@ const cancellationSources = {
...movementSource(g),
conversion: g.nexusAdvisorHistory?.find(record => record.stage === 'pending') ?? null,
}),
+ nexusGuildCunning: (g) => ({cunning:g.nexusGuildCunningHistory?.at(-1),active:g.active,remaining:g.movementRemaining}),
nexusSardaukar: (g) => ({
battle: battleSource(g),
declaration: g.nexusSardaukarHistory?.find(record => record.stage === 'pending') ?? null,
diff --git a/game/nexus-guild-cunning-options.ts b/game/nexus-guild-cunning-options.ts
new file mode 100644
index 00000000..eb310047
--- /dev/null
+++ b/game/nexus-guild-cunning-options.ts
@@ -0,0 +1,98 @@
+import type { Action, GameView } from './engine';
+import { ordinaryCardAvailability } from './card-availability';
+
+function owner(g: GameView) {
+ return g.players.find((p) => p.id === g.me);
+}
+function quiet(g: GameView) {
+ return (
+ g.status === 'playing' &&
+ g.phase === 5 &&
+ g.active === g.me &&
+ !g.response &&
+ !g.decision &&
+ !g.truthtrance &&
+ !g.phaseOpening &&
+ !g.automaticContinuationPending &&
+ !g.nexusCards?.waiting.length &&
+ !g.nexusTraitors?.pending
+ );
+}
+export function nexusGuildCunningActive(g: GameView) {
+ if (owner(g)?.faction !== 'guild' || g.phase !== 5 || g.active !== g.me)
+ return null;
+ const active = g.nexusGuildCunning?.active;
+ return active?.event === JSON.stringify(['nexusGuildCunning', g.turn, g.me])
+ ? active
+ : null;
+}
+
+/** The original shipped flag remains physical turn history. */
+export function nexusGuildShipmentAvailable(g: GameView): boolean {
+ const me = owner(g);
+ if (!me || !quiet(g)) return false;
+ const active = nexusGuildCunningActive(g);
+ if (me.faction === 'guild' && g.nexusGuildCunning?.active && !active)
+ return false;
+ return active ? active.stage === 'secondShipment' : !me.shipped;
+}
+export function nexusGuildMovementAvailable(g: GameView): boolean {
+ const me = owner(g);
+ if (!me || !quiet(g)) return false;
+ const active = nexusGuildCunningActive(g);
+ if (me.faction === 'guild' && g.nexusGuildCunning?.active && !active)
+ return false;
+ return active
+ ? active.stage === 'extraMove' && active.movesLeft > 0
+ : (me.moved ?? 0) < (me.movesAllowed ?? 1);
+}
+export function nexusGuildCunningAction(
+ g: GameView,
+ event: string,
+): Action | null {
+ const me = owner(g);
+ if (
+ !quiet(g) ||
+ me?.faction !== 'guild' ||
+ me.ally ||
+ g.nexusCards?.card !== 'guild'
+ )
+ return null;
+ const offer = g.nexusGuildCunning?.offer;
+ if (
+ !offer ||
+ offer.blocked ||
+ g.nexusGuildCunning?.active ||
+ event !== offer.event ||
+ event !== JSON.stringify(['nexusGuildCunning', g.turn, g.me])
+ )
+ return null;
+ return { type: 'endMovement', nexus: event };
+}
+
+/** Only the unused Hajr exception can prepare movement after the second shipment. */
+export function nexusGuildHajrAction(g: GameView): Action | null {
+ const active = nexusGuildCunningActive(g);
+ if (
+ !quiet(g) ||
+ active?.stage !== 'extraMove' ||
+ active.movesLeft > 0 ||
+ !active.hajrAvailable
+ )
+ return null;
+ const card = owner(g)?.hand?.find(
+ (c) => c.effect === 'hajr' && ordinaryCardAvailability(g, c.id)?.available,
+ );
+ return card ? { type: 'card', card: card.id } : null;
+}
+
+/** Passing this extra shipment preserves the separate unused Hajr choice. */
+export function nexusGuildSkipShipmentAction(
+ g: GameView,
+ event: string,
+): Action | null {
+ const active = nexusGuildCunningActive(g);
+ if (!quiet(g) || active?.stage !== 'secondShipment' || active.event !== event)
+ return null;
+ return { type: 'nexusGuildSkipShipment', event };
+}
diff --git a/game/nexus-guild-cunning.ts b/game/nexus-guild-cunning.ts
new file mode 100644
index 00000000..2279b60c
--- /dev/null
+++ b/game/nexus-guild-cunning.ts
@@ -0,0 +1,173 @@
+import { FACTIONS, type FactionId } from './catalog';
+
+export type NexusGuildCunningContext = {
+ turn: number;
+ players: readonly { id: string; faction: FactionId; ally?: string | null }[];
+};
+export type NexusGuildCunningBefore = {
+ shipped: boolean;
+ moved: number;
+ hajrUsed: boolean;
+};
+export type NexusGuildCunningReceipt = {
+ version: 1;
+ event: string;
+ owner: string;
+ turn: number;
+ phase: 5;
+ mode: 'cunning';
+ roster: { id: string; faction: FactionId }[];
+ before: NexusGuildCunningBefore;
+ signature: string;
+};
+
+function requireGuild(value: unknown, message: string): asserts value {
+ if (!value) throw new Error(message);
+}
+const identifier = (value: unknown): value is string =>
+ typeof value === 'string' &&
+ value.trim().length > 0 &&
+ !['__proto__', 'constructor', 'prototype'].includes(value);
+const plain = (value: unknown): value is Record =>
+ !!value &&
+ typeof value === 'object' &&
+ !Array.isArray(value) &&
+ [Object.prototype, null].includes(Object.getPrototypeOf(value));
+function roster(context: NexusGuildCunningContext) {
+ 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) && FACTIONS.some((f) => f.id === 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,
+ 'Guild Cunning requires its original faction roster and turn.',
+ );
+ return context.players
+ .map(({ id, faction }) => ({ id, faction }))
+ .sort((a, b) => a.id.localeCompare(b.id));
+}
+function validateBefore(before: NexusGuildCunningBefore) {
+ requireGuild(
+ plain(before) &&
+ Object.keys(before).sort().join(',') === 'hajrUsed,moved,shipped' &&
+ typeof before.shipped === 'boolean' &&
+ typeof before.hajrUsed === 'boolean' &&
+ Number.isSafeInteger(before.moved) &&
+ before.moved >= 0 &&
+ before.moved <= (before.hajrUsed ? 2 : 1),
+ 'Guild Cunning needs the original ordinary shipment and Hajr movement allowance.',
+ );
+}
+function signature(receipt: NexusGuildCunningReceipt) {
+ return JSON.stringify([
+ 'nexusGuildCunningReceipt',
+ receipt.version,
+ receipt.event,
+ receipt.owner,
+ receipt.turn,
+ receipt.phase,
+ receipt.mode,
+ receipt.roster.map((p) => [p.id, p.faction]),
+ [receipt.before.shipped, receipt.before.moved, receipt.before.hajrUsed],
+ ]);
+}
+/** Validates historical facts, not current cards, alliances, resources or movement.
+ * The engine binds this receipt to its actual completed first combined turn. */
+export function validateNexusGuildCunning(
+ context: NexusGuildCunningContext,
+ receipt: NexusGuildCunningReceipt,
+): void {
+ const seats = roster(context);
+ requireGuild(
+ plain(receipt) &&
+ Object.keys(receipt).sort().join(',') ===
+ 'before,event,mode,owner,phase,roster,signature,turn,version' &&
+ receipt.version === 1 &&
+ receipt.phase === 5 &&
+ receipt.mode === 'cunning' &&
+ Number.isSafeInteger(receipt.turn) &&
+ receipt.turn > 0 &&
+ receipt.turn <= context.turn &&
+ identifier(receipt.owner) &&
+ seats.some((p) => p.id === receipt.owner && p.faction === 'guild') &&
+ receipt.event ===
+ JSON.stringify(['nexusGuildCunning', 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),
+ 'Guild Cunning has changed its original owner, turn or faction roster.',
+ );
+ validateBefore(receipt.before);
+ requireGuild(
+ receipt.signature === signature(receipt),
+ 'Guild Cunning has changed its original combined turn.',
+ );
+}
+export function createNexusGuildCunning(
+ context: NexusGuildCunningContext & { phase: number; active: string | null },
+ owner: string,
+ before: NexusGuildCunningBefore,
+): NexusGuildCunningReceipt {
+ const seats = roster(context);
+ requireGuild(
+ context.phase === 5 &&
+ context.active === owner &&
+ context.players.some(
+ (p) => p.id === owner && p.faction === 'guild' && !p.ally,
+ ),
+ 'Guild Cunning requires the unallied native Guild at the end of its own combined turn.',
+ );
+ validateBefore(before);
+ const receipt: NexusGuildCunningReceipt = {
+ version: 1,
+ event: JSON.stringify(['nexusGuildCunning', context.turn, owner]),
+ owner,
+ turn: context.turn,
+ phase: 5,
+ mode: 'cunning',
+ roster: seats,
+ before: { ...before },
+ signature: '',
+ };
+ receipt.signature = signature(receipt);
+ validateNexusGuildCunning(context, receipt);
+ return receipt;
+}
+/** Ending ordinary movement forgoes at least its first slot. Hajr supplies only
+ * the still-unused extra slot, whether played before or after the shipment. */
+export function nexusGuildCunningMoves(
+ receipt: NexusGuildCunningReceipt,
+ currentHajrUsed: boolean,
+ movesAfterSecond = 0,
+): 0 | 1 {
+ validateNexusGuildCunning(
+ { turn: receipt.turn, players: receipt.roster },
+ receipt,
+ );
+ requireGuild(
+ typeof currentHajrUsed === 'boolean' &&
+ (!receipt.before.hajrUsed || currentHajrUsed) &&
+ Number.isSafeInteger(movesAfterSecond) &&
+ movesAfterSecond >= 0 &&
+ movesAfterSecond <= 1,
+ 'Guild Cunning has changed its remaining Hajr movement.',
+ );
+ const available = currentHajrUsed
+ ? Math.max(0, 2 - Math.max(1, receipt.before.moved))
+ : 0;
+ requireGuild(
+ movesAfterSecond <= available,
+ 'Guild Cunning cannot supply another ordinary movement.',
+ );
+ return (available - movesAfterSecond) as 0 | 1;
+}
diff --git a/game/reference.ts b/game/reference.ts
index 1daa1ace..6f1e989f 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 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 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.',
@@ -3368,16 +3368,17 @@ export const RULE_TOPICS: RuleTopic[] = [
'One Karama response covers the CHOAM effect. Cancellation leaves the chosen Treachery Card in hand and the Nexus spent. An allowed use discards the actual card once, retaining its printed identity and applicable discard consequences; it does not create a named Worthless card or a turn-long conversion permission.',
'Moritani Cunning modifies its normal Mentat placement: choose one available supply token and a territory on the printed Arrakis board, including a territory already containing Terror. The explicit Cunning toggle keeps ordinary placement and relocation unchanged. Homeworlds, the Hidden Mobile Stronghold, Cunning relocation and combined Grumman use are unavailable.',
'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.',
'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/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/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'] },
- { 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'] },
- { area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, support solver, territory-selection controls, bot and recovery regressions; Emperor Cunning browser checks pass while complete module games remain a separate requirement.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts'] },
+ { 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'] },
+ { area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, support solver, territory-selection controls, bot and recovery regressions; Emperor Cunning browser checks pass while complete module games remain a separate requirement.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts'] },
],
},
...[
diff --git a/game/richese-cancellation.ts b/game/richese-cancellation.ts
index b75109d2..9c372047 100644
--- a/game/richese-cancellation.ts
+++ b/game/richese-cancellation.ts
@@ -76,6 +76,7 @@ const RESPONSES = {
nexusPrescience: true,
nexusAdvisorFlip: true,
nexusSardaukar: true,
+ nexusGuildCunning: true,
advisor: true,
emperorIncome: true,
richesePurchaseIncome: true,
diff --git a/game/shipment-opportunity.ts b/game/shipment-opportunity.ts
new file mode 100644
index 00000000..864283a0
--- /dev/null
+++ b/game/shipment-opportunity.ts
@@ -0,0 +1,13 @@
+import type { Game, Player } from './engine';
+
+/** Rule callers validate the saved record before using this allowance. The
+ * original shipped flag remains history when Cunning grants another shipment. */
+export function shipmentAvailable(
+ g: Pick,
+ p: Pick,
+): boolean {
+ const last = g.nexusGuildCunningHistory?.at(-1);
+ const active = last && last.receipt.turn === g.turn && last.receipt.owner === p.id &&
+ ['pending', 'secondShipment', 'extraMove'].includes(last.stage);
+ return active ? last.stage === 'secondShipment' : !p.shipped;
+}
diff --git a/game/transport-quote.ts b/game/transport-quote.ts
index 74a70179..c7282b48 100644
--- a/game/transport-quote.ts
+++ b/game/transport-quote.ts
@@ -1,3 +1,4 @@
+import { nexusGuildShipmentAvailable } from './nexus-guild-cunning-options';
import type { Action, GameView } from './engine';
import { splitLocation, validLocation } from './board';
import { botEntryAllowed, guildTransportCost } from './bot-mobility';
@@ -122,10 +123,12 @@ export function guildTransportQuote(g: GameView, action: Action) {
unavailableReasons.push(
'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.');
if (
g.phase !== 5 ||
g.active !== p.id ||
- p.shipped ||
+ !nexusGuildShipmentAvailable(g) ||
!(
p.faction === 'guild' ||
g.players.some((seat) => seat.id === p.ally && seat.faction === 'guild')
diff --git a/game/truthtrance.ts b/game/truthtrance.ts
index 17e1ffb0..965fb707 100644
--- a/game/truthtrance.ts
+++ b/game/truthtrance.ts
@@ -1,3 +1,4 @@
+import { shipmentAvailable } from './shipment-opportunity';
import type { ShipmentClaim } from './shipment-promises';
import { TERRITORIES } from './board';
import {
@@ -145,7 +146,7 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
g.status === 'playing' &&
g.phase === 5 &&
g.active === v.target &&
- !g.players.find((p) => p.id === v.target)!.shipped &&
+ shipmentAvailable(g,g.players.find((p) => p.id === v.target)!) &&
!g.response &&
!g.decision &&
!g.phaseOpening &&
diff --git a/tests/fixture-nexus-guild-cunning.ts b/tests/fixture-nexus-guild-cunning.ts
new file mode 100644
index 00000000..f90011af
--- /dev/null
+++ b/tests/fixture-nexus-guild-cunning.ts
@@ -0,0 +1,161 @@
+import assert from 'node:assert/strict';
+import { applyAction, viewGame, type Game, type Action } from '../game/engine';
+import {
+ nexusRicheseFixture,
+ holdNexusRicheseCard,
+ type NexusRicheseFixtureOptions,
+} from './fixture-nexus-richese';
+import { nexusTraitorInventory } from './fixture-nexus-traitors';
+import { homeworldGameIntegrity } from '../game/homeworld-game';
+import { nexusAllow, nexusReload } from './fixture-nexus-cards';
+import { orderNexusSpice } from './fixture-nexus-cards';
+import { botActions } from '../game/bots';
+
+export type NexusGuildCunningFixture = {
+ g: Game;
+ owner: string;
+ target: string;
+ observer: string;
+ karama?: string;
+};
+/** Reuse genuine final native Guild setup, then exchange two physical Nexus
+ * identities before any Cunning declaration; no grant or progress is invented. */
+export function nexusGuildCunningFixture(
+ options: Pick<
+ NexusRicheseFixtureOptions,
+ | 'seatIds'
+ | 'advanced'
+ | 'homeworlds'
+ | 'spice'
+ | 'karama'
+ | 'opponentFaction'
+ > = {},
+): NexusGuildCunningFixture {
+ const f = nexusRicheseFixture({ ...options, ownerFaction: 'guild' });
+ 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';
+ nexusGuildCunningInventory(f.g);
+ return f;
+}
+/** Allow only the already declared shipment and its ordinary response chain. */
+export function settleGuildCunningShipment(g: Game): Game {
+ if (g.decision?.kind === 'guildShipment')
+ g = applyAction(g, g.decision.player, { type: 'decision', allow: true });
+ return nexusAllow(g);
+}
+export function originalGuildCunningTurn(
+ f: NexusGuildCunningFixture,
+ move = true,
+ g = f.g,
+): Game {
+ g = settleGuildCunningShipment(
+ applyAction(g, f.owner, {
+ type: 'ship',
+ territory: 'arrakeen',
+ sector: 10,
+ amount: 5,
+ }),
+ );
+ if (move)
+ g = applyAction(g, f.owner, {
+ type: 'move',
+ from: 'arrakeen:10',
+ territory: 'hagga_basin',
+ sector: 12,
+ amount: 5,
+ });
+ nexusGuildCunningInventory(g);
+ return g;
+}
+export function nexusGuildCunningRequest(
+ f: NexusGuildCunningFixture,
+ g = f.g,
+): Action {
+ return {
+ type: 'endMovement',
+ nexus: viewGame(g, f.owner).nexusGuildCunning!.offer!.event,
+ };
+}
+export const holdGuildCunningCard = holdNexusRicheseCard;
+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
+ // Homeworld custody against the unchanged game separately.
+ const census = structuredClone(g);
+ for (const p of census.players)
+ p.reserves += Object.values(g.homeworlds?.custody?.visitors ?? {}).reduce(
+ (sum, visitors) => {
+ const group = visitors[p.id];
+ return sum + (group?.normal ?? 0) + (group?.elite ?? 0);
+ },
+ 0,
+ );
+ nexusTraitorInventory(census);
+ if (g.homeworlds) homeworldGameIntegrity(g);
+}
+export const nexusGuildCunningReload = nexusReload;
+export const nexusGuildCunningAllow = nexusAllow;
+
+/** Place an actual native Terror token at the first Mentat, then complete the
+ * next Storm/Spice/auction/Revival before returning the Guild's real turn. */
+export function nexusGuildCunningAllianceFixture(
+ seatIds?: [string, string, string],
+): NexusGuildCunningFixture {
+ const f = nexusGuildCunningFixture({ 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: 'carthag',
+ }),
+ );
+ 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,
+ `Guild alliance fixture 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');
+ nexusGuildCunningInventory(g);
+ return { ...f, g };
+}
diff --git a/tests/nexus-guild-cunning-bots.test.ts b/tests/nexus-guild-cunning-bots.test.ts
new file mode 100644
index 00000000..7b943ade
--- /dev/null
+++ b/tests/nexus-guild-cunning-bots.test.ts
@@ -0,0 +1,142 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { applyAction, viewGame } from '../game/engine';
+import { botActions } from '../game/bots';
+import {
+ nexusGuildCunningFixture,
+ originalGuildCunningTurn,
+ nexusGuildCunningAllow,
+ settleGuildCunningShipment,
+ nexusGuildCunningInventory,
+ holdGuildCunningCard,
+} from './fixture-nexus-guild-cunning';
+
+void test('all Guild profiles finish the first turn through Cunning then propose a second shipment without ordinary movement', () => {
+ for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
+ const f = nexusGuildCunningFixture();
+ const first = originalGuildCunningTurn(f);
+ const v = viewGame(first, f.owner);
+ v.players.find((p) => p.id === f.owner)!.bot = profile;
+ const action = botActions(v)[0];
+ assert.equal(action.type, 'endMovement');
+ assert.equal(action.nexus, v.nexusGuildCunning!.offer!.event);
+ const g = nexusGuildCunningAllow(applyAction(first, f.owner, action));
+ const next = viewGame(g, f.owner);
+ next.players.find((p) => p.id === f.owner)!.bot = profile;
+ const candidates = botActions(next);
+ assert.ok(
+ candidates.some((a) => a.type === 'ship' || a.type === 'guildShip'),
+ );
+ assert.ok(candidates.every((a) => a.type !== 'move'));
+ const shipment = candidates.find(
+ (a) => a.type === 'ship' || a.type === 'guildShip',
+ )!;
+ nexusGuildCunningInventory(
+ settleGuildCunningShipment(applyAction(g, f.owner, shipment)),
+ );
+ }
+});
+void test('all Guild profiles use only an offered unused Hajr after the second shipment and preserve blocked ordinary fallback', () => {
+ for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
+ const f = nexusGuildCunningFixture();
+ const hajr = holdGuildCunningCard(f.g, f.owner, 'hajr');
+ const first = originalGuildCunningTurn(f);
+ const v = viewGame(first, f.owner);
+ v.players.find((p) => p.id === f.owner)!.bot = profile;
+ const declared = botActions(v)[0];
+ v.nexusGuildCunning!.offer!.blocked = 'Unavailable';
+ assert.equal(botActions(v)[0].nexus, undefined);
+ let g = nexusGuildCunningAllow(applyAction(first, f.owner, declared));
+ g = settleGuildCunningShipment(
+ applyAction(g, f.owner, {
+ type: 'ship',
+ territory: 'carthag',
+ sector: 11,
+ amount: 3,
+ }),
+ );
+ const after = viewGame(g, f.owner);
+ after.players.find((p) => p.id === f.owner)!.bot = profile;
+ assert.deepEqual(botActions(after)[0], { type: 'card', card: hajr.id });
+ const prepared = applyAction(g, f.owner, botActions(after)[0]);
+ const pv = viewGame(prepared, f.owner);
+ pv.players.find((p) => p.id === f.owner)!.bot = profile;
+ const move = botActions(pv).find((a) => a.type === 'move');
+ assert.ok(move);
+ nexusGuildCunningInventory(applyAction(prepared, f.owner, move));
+ }
+});
+
+void test('all Guild profiles skip an unaffordable second shipment to retain their owned unused Hajr', () => {
+ for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
+ const f = nexusGuildCunningFixture({ spice: 3 }),
+ hajr = holdGuildCunningCard(f.g, f.owner, 'hajr');
+ const first = originalGuildCunningTurn(f),
+ fv = viewGame(first, f.owner);
+ fv.players[0].bot = profile;
+ const g = nexusGuildCunningAllow(
+ applyAction(first, f.owner, botActions(fv)[0]),
+ );
+ const v = viewGame(g, f.owner);
+ v.players[0].bot = profile;
+ const skip = botActions(v)[0];
+ assert.equal(skip.type, 'nexusGuildSkipShipment');
+ const next = applyAction(g, f.owner, skip),
+ nv = viewGame(next, f.owner);
+ nv.players[0].bot = profile;
+ assert.deepEqual(botActions(nv)[0], { type: 'card', card: hajr.id });
+ nexusGuildCunningInventory(applyAction(next, f.owner, botActions(nv)[0]));
+ }
+});
+
+void test('a new answered Truthtrance binds the second shipment and all profiles honor its six-force witness despite shipped true', () => {
+ for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
+ const f = nexusGuildCunningFixture();
+ const truth = holdGuildCunningCard(f.g, f.target, 'truthtrance');
+ const first = originalGuildCunningTurn(f);
+ const fv = viewGame(first, f.owner);
+ fv.players[0].bot = profile;
+ let g = nexusGuildCunningAllow(
+ applyAction(first, f.owner, botActions(fv)[0]),
+ );
+ g = applyAction(g, f.target, { type: 'card', card: truth.id });
+ for (let n = 0; n < 10 && g.truthtrance?.stage === 'priority'; n++)
+ 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: 'carthag',
+ minimum: 6,
+ },
+ });
+ assert.ok(viewGame(g, f.owner).truthShipmentAnswers!.includes('yes'));
+ g = applyAction(g, f.owner, { type: 'truthAnswer', answer: 'yes' });
+ const v = viewGame(g, f.owner);
+ v.players[0].bot = profile;
+ assert.equal(v.players[0].shipped, true);
+ const actions = botActions(v);
+ assert.ok(actions.length);
+ assert.ok(
+ actions.every(
+ (a) =>
+ a.type === 'ship' &&
+ a.territory === 'carthag' &&
+ Number(a.amount) >= 6,
+ ),
+ );
+ const done = settleGuildCunningShipment(
+ applyAction(g, f.owner, actions[0]),
+ );
+ assert.ok(
+ done.players.find((p) => p.id === f.owner)!.forces['carthag:11'] >= 6,
+ );
+ assert.ok(done.shipmentPromises!.every((p) => p.fulfilled && !p.released));
+ nexusGuildCunningInventory(done);
+ }
+});
diff --git a/tests/nexus-guild-cunning-controls.test.ts b/tests/nexus-guild-cunning-controls.test.ts
new file mode 100644
index 00000000..22d3496d
--- /dev/null
+++ b/tests/nexus-guild-cunning-controls.test.ts
@@ -0,0 +1,265 @@
+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 {
+ nexusGuildCunningAction,
+ nexusGuildShipmentAvailable,
+ nexusGuildMovementAvailable,
+ nexusGuildSkipShipmentAction,
+} from '../game/nexus-guild-cunning-options';
+import { ordinaryCardAvailability } from '../game/card-availability';
+import { guildTransportQuote } from '../game/transport-quote';
+import { guildHomeworldShipmentChoice } from '../game/guild-homeworld-shipment-options';
+import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
+import {
+ nexusGuildCunningFixture,
+ originalGuildCunningTurn,
+ nexusGuildCunningRequest,
+ nexusGuildCunningAllow,
+ settleGuildCunningShipment,
+ nexusGuildCunningInventory,
+ holdGuildCunningCard,
+} from './fixture-nexus-guild-cunning';
+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() {},
+ }),
+ );
+function second(homeworlds = false) {
+ const f = nexusGuildCunningFixture({ homeworlds });
+ const original = originalGuildCunningTurn(f);
+ const g = nexusGuildCunningAllow(
+ applyAction(original, f.owner, nexusGuildCunningRequest(f, original)),
+ );
+ return { f, original, g };
+}
+
+void test('Guild declaration controls bind the owned current ending opportunity, leave first-turn history intact and protect observers', () => {
+ for (const advanced of [false, true]) {
+ const f = nexusGuildCunningFixture({ advanced, karama: true });
+ const original = originalGuildCunningTurn(f);
+ const v = viewGame(original, f.owner),
+ event = v.nexusGuildCunning!.offer!.event;
+ const before = structuredClone(v);
+ const action = nexusGuildCunningAction(v, event)!;
+ assert.deepEqual(action, { type: 'endMovement', nexus: event });
+ assert.equal(nexusGuildCunningAction(v, 'stale'), null);
+ assert.match(html(v), /Finish ordinary turn and declare Guild Cunning/);
+ assert.match(html(v, true), /disabled=""/);
+ const pending = applyAction(original, f.owner, action);
+ assert.equal(pending.response?.kind, 'nexusGuildCunning');
+ const pv = viewGame(pending, f.owner);
+ assert.match(
+ html(pv),
+ /Allow one second Guild shipment at its normal price/,
+ );
+ assert.doesNotMatch(
+ html(pv),
+ /"nexusGuildCunning"|\["nexusGuildCunning"/,
+ );
+ assert.equal(nexusGuildShipmentAvailable(pv), false);
+ assert.equal(nexusGuildMovementAvailable(pv), false);
+ assert.equal(pv.players.find((p) => p.id === f.owner)!.shipped, true);
+ assert.equal(pv.players.find((p) => p.id === f.owner)!.moved, 1);
+ for (const id of [f.target, f.observer]) {
+ const other = viewGame(original, id);
+ assert.equal(other.nexusGuildCunning, null);
+ Object.defineProperty(other, 'nexusGuildCunning', {
+ get() {
+ throw Error('Private Guild grant read');
+ },
+ });
+ assert.equal(nexusGuildCunningAction(other, event), null);
+ }
+ assert.deepEqual(v, before);
+ }
+});
+
+void test('second shipment controls and ordinary transport quote reopen only the new shipment despite shipped true', () => {
+ const { f, g } = second();
+ holdGuildCunningCard(g, f.owner, 'karama');
+ const v = viewGame(g, f.owner);
+ const before = structuredClone(v);
+ assert.equal(v.players.find((p) => p.id === f.owner)!.shipped, true);
+ assert.equal(nexusGuildShipmentAvailable(v), true);
+ assert.equal(nexusGuildMovementAvailable(v), false);
+ const markup = html(v);
+ assert.match(markup, /Ship from reserves/);
+ assert.match(markup, /Guild rates for/);
+ assert.match(markup, /Decline second shipment and finish/);
+ assert.match(markup, /This does not reopen ordinary movement/);
+ const action = {
+ type: 'guildShip',
+ from: 'hagga_basin:12',
+ territory: 'carthag',
+ sector: 11,
+ amount: 3,
+ };
+ const same = { ...action, territory: 'hagga_basin', sector: 12 };
+ assert.ok(
+ guildTransportQuote(v, same).unavailableReasons.some((reason) =>
+ /another territory/.test(reason),
+ ),
+ );
+ const originalState = structuredClone(g);
+ assert.throws(
+ () => applyAction(g, f.owner, same),
+ /another territory|same territory|different destination territory/,
+ );
+ assert.deepEqual(g, originalState);
+ const ordinary = nexusGuildCunningFixture();
+ // Conserved ordinary board source before any declaration, not a fabricated grant.
+ ordinary.g.players[0].reserves -= 3;
+ ordinary.g.players[0].forces['hagga_basin:12'] = 3;
+ const ordinaryView = viewGame(ordinary.g, ordinary.owner);
+ assert.equal(nexusGuildShipmentAvailable(ordinaryView), true);
+ assert.ok(
+ guildTransportQuote(ordinaryView, same).unavailableReasons.some((reason) =>
+ /another territory/.test(reason),
+ ),
+ );
+ const ordinaryBefore = structuredClone(ordinary.g);
+ assert.throws(
+ () => applyAction(ordinary.g, ordinary.owner, same),
+ /another territory|same territory|different destination territory/,
+ );
+ assert.deepEqual(ordinary.g, ordinaryBefore);
+ const quote = guildTransportQuote(v, action);
+ assert.deepEqual(quote.unavailableReasons, []);
+ const next = settleGuildCunningShipment(applyAction(g, f.owner, action));
+ assert.equal(
+ next.players.find((p) => p.id === f.owner)!.forces['carthag:11'],
+ 3,
+ );
+ nexusGuildCunningInventory(next);
+ const stale = structuredClone(v);
+ stale.nexusGuildCunning!.active!.event = 'stale';
+ assert.equal(nexusGuildShipmentAvailable(stale), false);
+ assert.deepEqual(v, before);
+});
+
+void test('both native Homeworld shipment controls consume the second grant with typed source quotes', () => {
+ const { f, g } = second(true),
+ v = viewGame(g, f.owner);
+ assert.equal(nexusGuildShipmentAvailable(v), true);
+ const outward = homeworldShipmentChoice(v, 'homeworld:harkonnen', {
+ 'homeworld:guild': { normal: 2, elite: 0 },
+ });
+ assert.equal(outward.blocked, null);
+ assert.ok(outward.action);
+ nexusGuildCunningInventory(
+ settleGuildCunningShipment(applyAction(g, f.owner, outward.action)),
+ );
+ const back = guildHomeworldShipmentChoice(v, 'homeworld:guild', {
+ 'hagga_basin:12': { normal: 3, elite: 0 },
+ });
+ assert.equal(back.blocked, null);
+ assert.ok(back.action);
+ const restored = settleGuildCunningShipment(
+ applyAction(g, f.owner, back.action),
+ );
+ assert.equal(restored.players.find((p) => p.id === f.owner)!.reserves, 18);
+ nexusGuildCunningInventory(restored);
+ assert.match(html(v), /Ship to another Homeworld/);
+});
+
+void test('Hajr availability uses the explicit unused-card permission rather than the derived movement ceiling', () => {
+ const f = nexusGuildCunningFixture();
+ const hajr = holdGuildCunningCard(f.g, f.owner, 'hajr');
+ const original = originalGuildCunningTurn(f);
+ let g = nexusGuildCunningAllow(
+ applyAction(original, f.owner, nexusGuildCunningRequest(f, original)),
+ );
+ g = settleGuildCunningShipment(
+ applyAction(g, f.owner, {
+ type: 'ship',
+ territory: 'carthag',
+ sector: 11,
+ amount: 2,
+ }),
+ );
+ const v = viewGame(g, f.owner);
+ assert.equal(v.nexusGuildCunning!.active!.stage, 'extraMove');
+ assert.equal(ordinaryCardAvailability(v, hajr.id)?.available, true);
+ const played = applyAction(g, f.owner, { type: 'card', card: hajr.id });
+ const pv = viewGame(played, f.owner);
+ assert.equal(nexusGuildMovementAvailable(pv), true);
+ assert.equal(pv.nexusGuildCunning!.active!.hajrAvailable, false);
+ // Consumer-only alias of a retained physical card tests the explicit flag, without engine evidence claims.
+ pv.players.find((p) => p.id === f.owner)!.hand!.push(hajr);
+ pv.players.find((p) => p.id === f.owner)!.movesAllowed = 1;
+ assert.equal(ordinaryCardAvailability(pv, hajr.id)?.available, false);
+ nexusGuildCunningInventory(played);
+});
+
+void test('skipping only the second shipment retains a real Hajr choice with no shipment payment or force movement', () => {
+ const f = nexusGuildCunningFixture({ spice: 3 });
+ const hajr = holdGuildCunningCard(f.g, f.owner, 'hajr');
+ const first = originalGuildCunningTurn(f);
+ const g = nexusGuildCunningAllow(
+ applyAction(first, f.owner, nexusGuildCunningRequest(f, first)),
+ );
+ const v = viewGame(g, f.owner),
+ event = v.nexusGuildCunning!.active!.event;
+ assert.match(html(v), /Skip second shipment/);
+ assert.match(html(v), /Decline second shipment and finish/);
+ assert.equal(nexusGuildSkipShipmentAction(v, 'stale'), null);
+ const before = g.players.find((p) => p.id === f.owner)!;
+ const skipped = applyAction(
+ g,
+ f.owner,
+ nexusGuildSkipShipmentAction(v, event)!,
+ );
+ const sv = viewGame(skipped, f.owner),
+ after = skipped.players.find((p) => p.id === f.owner)!;
+ assert.equal(sv.nexusGuildCunning!.active!.stage, 'extraMove');
+ assert.equal(nexusGuildShipmentAvailable(sv), false);
+ assert.equal(nexusGuildMovementAvailable(sv), false);
+ assert.equal(ordinaryCardAvailability(sv, hajr.id)?.available, true);
+ assert.equal(after.spice, before.spice);
+ assert.equal(after.reserves, before.reserves);
+ assert.deepEqual(after.forces, before.forces);
+ assert.equal(after.shipped, before.shipped);
+ assert.equal(nexusGuildSkipShipmentAction(sv, event), null);
+ const prepared = applyAction(skipped, f.owner, {
+ type: 'card',
+ card: hajr.id,
+ });
+ assert.equal(nexusGuildMovementAvailable(viewGame(prepared, f.owner)), true);
+ nexusGuildCunningInventory(prepared);
+});
diff --git a/tests/nexus-guild-cunning-engine.test.ts b/tests/nexus-guild-cunning-engine.test.ts
new file mode 100644
index 00000000..fa5f0065
--- /dev/null
+++ b/tests/nexus-guild-cunning-engine.test.ts
@@ -0,0 +1,482 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ applyAction,
+ normalizeAutomaticGame,
+ viewGame,
+ type Game,
+ type Action,
+} from '../game/engine';
+import {
+ nexusGuildCunningFixture,
+ originalGuildCunningTurn,
+ nexusGuildCunningRequest,
+ nexusGuildCunningAllow,
+ nexusGuildCunningReload,
+ nexusGuildCunningInventory,
+ settleGuildCunningShipment,
+ holdGuildCunningCard,
+ nexusGuildCunningAllianceFixture,
+} from './fixture-nexus-guild-cunning';
+function rejected(g: Game, id: string, action: Action) {
+ const before = structuredClone(g);
+ assert.throws(() => applyAction(g, id, action));
+ assert.deepEqual(g, before);
+}
+function stable(g: Game) {
+ nexusGuildCunningInventory(g);
+ for (const p of g.players)
+ assert.deepEqual(
+ viewGame(nexusGuildCunningReload(g), p.id),
+ viewGame(g, p.id),
+ );
+ assert.deepEqual(
+ normalizeAutomaticGame(nexusGuildCunningReload(g)),
+ nexusGuildCunningReload(g),
+ );
+}
+
+void test('an actual Moritani alliance formed on the second arrival preserves the earned Guild Hajr continuation', () => {
+ const f = nexusGuildCunningAllianceFixture();
+ const hajr = holdGuildCunningCard(f.g, f.owner, 'hajr');
+ let g = originalGuildCunningTurn(f);
+ g = nexusGuildCunningAllow(
+ applyAction(g, f.owner, nexusGuildCunningRequest(f, g)),
+ );
+ g = settleGuildCunningShipment(
+ applyAction(g, f.owner, {
+ type: 'ship',
+ territory: 'carthag',
+ sector: 11,
+ amount: 3,
+ }),
+ );
+ assert.equal(g.pendingTerrorEntry?.stage, 'offer');
+ g = nexusGuildCunningAllow(
+ applyAction(g, f.target, { type: 'decision', alliance: true }),
+ );
+ stable(g);
+ const before = structuredClone(g.players[0]);
+ g = applyAction(nexusGuildCunningReload(g), f.owner, {
+ type: 'decision',
+ accept: true,
+ });
+ assert.equal(g.players[0].ally, f.target);
+ assert.equal(g.players[1].ally, f.owner);
+ assert.equal(g.players[0].spice, before.spice);
+ assert.equal(g.players[0].reserves, before.reserves);
+ stable(g);
+ g = applyAction(g, f.owner, { type: 'card', card: hajr.id });
+ g = applyAction(g, f.owner, {
+ type: 'move',
+ from: 'carthag:11',
+ territory: 'hagga_basin',
+ sector: 12,
+ amount: 3,
+ });
+ assert.equal(g.players[0].forces['hagga_basin:12'], 8);
+ assert.equal(g.players[0].spice, before.spice);
+ if (g.active === f.owner)
+ g = applyAction(g, f.owner, { type: 'endMovement' });
+ assert.notEqual(g.active, f.owner);
+ stable(g);
+});
+
+void test('Guild special Karama stops its own second shipment without payment or arrival and preserves only the unused Hajr move', () => {
+ for (const homeworlds of [false, true]) {
+ const f = nexusGuildCunningFixture({ advanced: true, homeworlds });
+ const karama = holdGuildCunningCard(f.g, f.owner, 'karama');
+ const hajr = holdGuildCunningCard(f.g, f.owner, 'hajr');
+ let g = originalGuildCunningTurn(f, false);
+ g = nexusGuildCunningAllow(
+ applyAction(g, f.owner, nexusGuildCunningRequest(f, g)),
+ );
+ const before = structuredClone(g.players[0]);
+ const shipment: Action = homeworlds
+ ? {
+ type: 'homeworldShip',
+ event: viewGame(g, f.owner).homeworldShipment!.event,
+ destination: 'homeworld:harkonnen',
+ sources: { 'homeworld:guild': { normal: 3, elite: 0 } },
+ }
+ : { type: 'ship', territory: 'carthag', sector: 11, amount: 3 };
+ g = applyAction(g, f.owner, shipment);
+ assert.equal(
+ g.decision?.kind,
+ homeworlds ? 'homeworldShipmentGuild' : 'guildShipment',
+ );
+ stable(g);
+ g = applyAction(nexusGuildCunningReload(g), f.owner, {
+ type: 'card',
+ mode: 'special',
+ card: karama.id,
+ });
+ assert.equal(
+ g.nexusGuildCunningHistory!.at(-1)!.shipment!.stage,
+ 'stopped',
+ );
+ assert.equal(g.players[0].spice, before.spice);
+ assert.equal(g.players[0].reserves, before.reserves);
+ assert.deepEqual(g.players[0].forces, before.forces);
+ assert.equal(g.nexusCards!.cards!.hands[f.owner], null);
+ assert.equal(
+ g.nexusCards!.cards!.discard.filter((c) => c === 'guild').length,
+ 1,
+ );
+ if (homeworlds)
+ assert.equal(
+ g.homeworlds!.custody!.visitors['homeworld:harkonnen']?.[f.owner],
+ undefined,
+ );
+ const move: Action = {
+ type: 'move',
+ from: 'arrakeen:10',
+ territory: 'hagga_basin',
+ sector: 12,
+ amount: 5,
+ };
+ rejected(g, f.owner, move);
+ rejected(g, f.owner, shipment);
+ g = applyAction(nexusGuildCunningReload(g), f.owner, {
+ type: 'card',
+ card: hajr.id,
+ });
+ g = applyAction(g, f.owner, move);
+ rejected(g, f.owner, {
+ type: 'move',
+ from: 'hagga_basin:12',
+ territory: 'arsunt',
+ sector: 12,
+ amount: 5,
+ });
+ assert.equal(g.players[0].spice, before.spice);
+ stable(g);
+ }
+});
+
+void test('native and Cunning cross-shipments cannot manufacture a shipment within the same territory', () => {
+ const f = nexusGuildCunningFixture();
+ const native = nexusGuildCunningFixture();
+ native.g.players[0].reserves -= 5;
+ native.g.players[0].forces['arrakeen:10'] = 5;
+ nexusGuildCunningInventory(native.g);
+ let g = originalGuildCunningTurn(f, false);
+ const same: Action = {
+ type: 'guildShip',
+ from: 'arrakeen:10',
+ territory: 'arrakeen',
+ sector: 10,
+ amount: 1,
+ };
+ rejected(native.g, native.owner, same);
+ g = nexusGuildCunningAllow(
+ applyAction(g, f.owner, nexusGuildCunningRequest(f, g)),
+ );
+ rejected(g, f.owner, same);
+ stable(g);
+});
+
+void test('Guild Cunning adds a paid native reserve, cross or return shipment after the actual original movement without resetting flags', () => {
+ for (const advanced of [false, true])
+ for (const route of ['reserve', 'cross', 'return'] as const) {
+ const f = nexusGuildCunningFixture({ advanced, karama: true });
+ let g = originalGuildCunningTurn(f);
+ const before = nexusGuildCunningReload(g);
+ const request = nexusGuildCunningRequest(f, g);
+ g = applyAction(g, f.owner, request);
+ assert.equal(g.response?.kind, 'nexusGuildCunning');
+ assert.equal(g.players[0].shipped, true);
+ assert.equal(g.players[0].moved, 1);
+ assert.equal(g.nexusCards!.cards!.hands[f.owner], null);
+ stable(g);
+ g = nexusGuildCunningAllow(nexusGuildCunningReload(g));
+ assert.equal(
+ viewGame(g, f.owner).nexusGuildCunning!.active!.stage,
+ 'secondShipment',
+ );
+ const action: Action =
+ route === 'reserve'
+ ? { type: 'ship', territory: 'carthag', sector: 11, amount: 5 }
+ : {
+ type: 'guildShip',
+ from: 'hagga_basin:12',
+ territory: route === 'return' ? 'reserves' : 'carthag',
+ sector: route === 'return' ? 0 : 11,
+ amount: 5,
+ };
+ g = settleGuildCunningShipment(applyAction(g, f.owner, action));
+ assert.equal(g.players[0].spice, before.players[0].spice - 3);
+ assert.equal(g.players[0].moved, 1);
+ assert.equal(g.players[0].shipped, true);
+ assert.equal(
+ g.players[0].reserves,
+ route === 'reserve' ? 10 : route === 'return' ? 20 : 15,
+ );
+ if (route !== 'return')
+ assert.equal(g.players[0].forces['carthag:11'], 5);
+ rejected(g, f.owner, action);
+ rejected(g, f.owner, {
+ type: 'move',
+ from: 'carthag:11',
+ territory: 'hagga_basin',
+ sector: 12,
+ amount: 1,
+ });
+ assert.equal(
+ g.nexusCards!.cards!.discard.filter((c) => c === 'guild').length,
+ 1,
+ );
+ stable(g);
+ }
+});
+
+void test('canceling Guild Cunning preserves the first paid shipment and movement and does not reset the real turn queue', () => {
+ for (const advanced of [false, true]) {
+ const f = nexusGuildCunningFixture({ advanced, karama: true });
+ let g = originalGuildCunningTurn(f);
+ const before = nexusGuildCunningReload(g),
+ request = nexusGuildCunningRequest(f, g);
+ g = applyAction(g, f.owner, request);
+ g = applyAction(nexusGuildCunningReload(g), f.target, {
+ type: 'card',
+ mode: 'cancel',
+ card: f.karama,
+ });
+ assert.deepEqual(g.players[0].forces, before.players[0].forces);
+ assert.equal(g.players[0].spice, before.players[0].spice);
+ assert.equal(g.players[0].moved, 1);
+ assert.equal(g.players[0].shipped, true);
+ assert.equal(g.movementRemaining!.includes(f.owner), false);
+ assert.notEqual(g.active, f.owner);
+ assert.equal(g.discard.filter((c) => c.id === f.karama).length, 1);
+ rejected(g, f.owner, request);
+ rejected(g, f.owner, {
+ type: 'ship',
+ territory: 'carthag',
+ sector: 11,
+ amount: 1,
+ });
+ stable(g);
+ }
+});
+
+void test('declining ordinary movement does not leave a free movement after the second Guild shipment', () => {
+ for (const originalShip of [false, true]) {
+ const f = nexusGuildCunningFixture();
+ let g = originalShip ? originalGuildCunningTurn(f, false) : f.g;
+ g = nexusGuildCunningAllow(
+ applyAction(g, f.owner, nexusGuildCunningRequest(f, g)),
+ );
+ g = settleGuildCunningShipment(
+ applyAction(g, f.owner, {
+ type: 'ship',
+ territory: 'carthag',
+ sector: 11,
+ amount: 3,
+ }),
+ );
+ assert.equal(g.players[0].moved, 0);
+ rejected(g, f.owner, {
+ type: 'move',
+ from: 'carthag:11',
+ territory: 'hagga_basin',
+ sector: 12,
+ amount: 3,
+ });
+ stable(g);
+ }
+});
+
+void test('Hajr grants only its remaining extra movement before or after the second shipment and cannot create a third move', () => {
+ for (const mode of ['late', 'early-unspent', 'early-used'] as const) {
+ const f = nexusGuildCunningFixture();
+ const hajr = holdGuildCunningCard(f.g, f.owner, 'hajr');
+ let g = originalGuildCunningTurn(f);
+ if (mode !== 'late')
+ g = applyAction(g, f.owner, { type: 'card', card: hajr.id });
+ if (mode === 'early-used')
+ g = applyAction(g, f.owner, {
+ type: 'move',
+ from: 'hagga_basin:12',
+ territory: 'arsunt',
+ sector: 12,
+ amount: 5,
+ });
+ g = nexusGuildCunningAllow(
+ applyAction(g, f.owner, nexusGuildCunningRequest(f, g)),
+ );
+ g = settleGuildCunningShipment(
+ applyAction(g, f.owner, {
+ type: 'ship',
+ territory: 'carthag',
+ sector: 11,
+ amount: 3,
+ }),
+ );
+ if (mode === 'late')
+ g = applyAction(g, f.owner, { type: 'card', card: hajr.id });
+ const move: Action = {
+ type: 'move',
+ from: 'carthag:11',
+ territory: 'hagga_basin',
+ sector: 12,
+ amount: 3,
+ };
+ if (mode === 'early-used') rejected(g, f.owner, move);
+ else {
+ g = applyAction(g, f.owner, move);
+ assert.equal(g.players[0].moved, 2);
+ rejected(g, f.owner, {
+ type: 'move',
+ from: 'hagga_basin:12',
+ territory: 'arrakeen',
+ sector: 10,
+ amount: 1,
+ });
+ }
+ assert.equal(g.discard.filter((c) => c.id === hajr.id).length, 1);
+ stable(g);
+ }
+});
+
+void test('declining the second shipment preserves paid first forces and permits only an unused Hajr move after declining ordinary movement', () => {
+ const f = nexusGuildCunningFixture();
+ const hajr = holdGuildCunningCard(f.g, f.owner, 'hajr');
+ let g = originalGuildCunningTurn(f, false);
+ g = nexusGuildCunningAllow(
+ applyAction(g, f.owner, nexusGuildCunningRequest(f, g)),
+ );
+ const skip: Action = {
+ type: 'nexusGuildSkipShipment',
+ event: viewGame(g, f.owner).nexusGuildCunning!.active!.event,
+ };
+ rejected(g, f.target, skip);
+ rejected(g, f.owner, { ...skip, event: 'stale' });
+ g = applyAction(g, f.owner, skip);
+ assert.equal(g.players[0].spice, 17);
+ assert.equal(g.players[0].reserves, 15);
+ assert.equal(g.players[0].shipped, true);
+ assert.equal(g.players[0].moved, 0);
+ stable(g);
+ const move: Action = {
+ type: 'move',
+ from: 'arrakeen:10',
+ territory: 'hagga_basin',
+ sector: 12,
+ amount: 5,
+ };
+ rejected(g, f.owner, move);
+ rejected(g, f.owner, skip);
+ g = applyAction(nexusGuildCunningReload(g), f.owner, {
+ type: 'card',
+ card: hajr.id,
+ });
+ g = applyAction(g, f.owner, move);
+ assert.equal(g.players[0].forces['hagga_basin:12'], 5);
+ assert.equal(g.players[0].spice, 17);
+ rejected(g, f.owner, {
+ type: 'move',
+ from: 'hagga_basin:12',
+ territory: 'arsunt',
+ sector: 12,
+ amount: 5,
+ });
+ assert.equal(g.discard.filter((c) => c.id === hajr.id).length, 1);
+ stable(g);
+});
+
+void test('Guild Cunning keeps declared ownership private before play and rejects stale, foreign or duplicate declarations', () => {
+ const f = nexusGuildCunningFixture();
+ let g = originalGuildCunningTurn(f);
+ const request = nexusGuildCunningRequest(f, g);
+ for (const id of [f.target, f.observer])
+ assert.equal(viewGame(g, id).nexusGuildCunning, null);
+ rejected(g, f.owner, { ...request, nexus: 'old' });
+ rejected(g, f.target, request);
+ g = nexusGuildCunningAllow(applyAction(g, f.owner, request));
+ rejected(g, f.owner, request);
+ for (const p of g.players)
+ assert.equal(
+ Object.hasOwn(viewGame(g, p.id), 'nexusGuildCunningHistory'),
+ false,
+ );
+ stable(g);
+});
+
+void test('a second Guild shipment can return actual Arrakis counters to Junction through the typed native Homeworld route', () => {
+ const f = nexusGuildCunningFixture({ homeworlds: true });
+ let g = originalGuildCunningTurn(f);
+ g = nexusGuildCunningAllow(
+ applyAction(g, f.owner, nexusGuildCunningRequest(f, g)),
+ );
+ const option = viewGame(g, f.owner).guildHomeworldShipment!;
+ assert.equal(option.blocked, null);
+ g = applyAction(g, f.owner, {
+ type: 'guildHomeworldShip',
+ event: option.event,
+ destination: 'homeworld:guild',
+ sources: { 'hagga_basin:12': { normal: 5, elite: 0 } },
+ });
+ assert.equal(g.players[0].reserves, 20);
+ assert.equal(g.players[0].forces['hagga_basin:12'], undefined);
+ assert.equal(g.players[0].spice, 14);
+ stable(g);
+ const outward = nexusGuildCunningFixture({ homeworlds: true });
+ let next = originalGuildCunningTurn(outward);
+ next = nexusGuildCunningAllow(
+ applyAction(next, outward.owner, nexusGuildCunningRequest(outward, next)),
+ );
+ const outwardOption = viewGame(next, outward.owner).homeworldShipment!;
+ assert.equal(outwardOption.blocked, null);
+ next = applyAction(next, outward.owner, {
+ type: 'homeworldShip',
+ event: outwardOption.event,
+ destination: 'homeworld:harkonnen',
+ sources: { 'homeworld:guild': { normal: 2, elite: 0 } },
+ });
+ assert.equal(next.players[0].reserves, 13);
+ assert.equal(
+ next.homeworlds!.custody!.visitors['homeworld:harkonnen'][outward.owner]
+ .normal,
+ 2,
+ );
+ assert.equal(next.players[0].spice, 16);
+ stable(next);
+});
+
+void test('Advanced Guild Cunning finishes within a genuinely granted out-of-order combined turn', () => {
+ const f = nexusGuildCunningFixture({ advanced: true, karama: true });
+ let g = f.g;
+ // The first phase-five position is only fixture staging. Enter its native
+ // turn queue through an actual completed Revival boundary before acting.
+ Object.assign(g, {
+ phase: 4,
+ active: null,
+ ready: [],
+ order: [f.target, f.observer, f.owner],
+ });
+ for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
+ assert.equal(g.decision?.kind, 'guildTiming');
+ g = applyAction(g, f.owner, { type: 'decision', take: true });
+ g = nexusGuildCunningAllow(g);
+ assert.equal(g.active, f.owner);
+ assert.equal(g.guildTimingGranted, true);
+ g = originalGuildCunningTurn(f, true, g);
+ g = nexusGuildCunningAllow(
+ applyAction(g, f.owner, nexusGuildCunningRequest(f, g)),
+ );
+ g = settleGuildCunningShipment(
+ applyAction(g, f.owner, {
+ type: 'ship',
+ territory: 'carthag',
+ sector: 11,
+ amount: 3,
+ }),
+ );
+ if (g.active === f.owner)
+ g = applyAction(g, f.owner, { type: 'endMovement' });
+ assert.equal(g.movementRemaining!.includes(f.owner), false);
+ assert.equal(g.active, f.target);
+ assert.equal(new Set(g.movementRemaining).size, g.movementRemaining!.length);
+ stable(g);
+});
diff --git a/tests/nexus-guild-cunning-interruptions.test.ts b/tests/nexus-guild-cunning-interruptions.test.ts
new file mode 100644
index 00000000..6d8e9d79
--- /dev/null
+++ b/tests/nexus-guild-cunning-interruptions.test.ts
@@ -0,0 +1,160 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ applyAction,
+ viewGame,
+ normalizeAutomaticGame,
+ type Game,
+} from '../game/engine';
+import {
+ nexusGuildCunningFixture,
+ originalGuildCunningTurn,
+ nexusGuildCunningRequest,
+ holdGuildCunningCard,
+ nexusGuildCunningAllow,
+ nexusGuildCunningInventory,
+ nexusGuildCunningReload,
+} from './fixture-nexus-guild-cunning';
+
+function stable(g: Game) {
+ nexusGuildCunningInventory(g);
+ for (const p of g.players)
+ assert.deepEqual(
+ viewGame(nexusGuildCunningReload(g), p.id),
+ viewGame(g, p.id),
+ );
+ assert.deepEqual(normalizeAutomaticGame(nexusGuildCunningReload(g)), g);
+}
+function nested() {
+ const f = nexusGuildCunningFixture({
+ advanced: true,
+ opponentFaction: 'beneGesserit',
+ });
+ const worthless = holdGuildCunningCard(f.g, f.target, 'worthless');
+ const karama = holdGuildCunningCard(f.g, f.observer, 'karama');
+ let g = originalGuildCunningTurn(f, false);
+ assert.equal(g.decision?.kind, 'advisor');
+ g = applyAction(g, f.target, { type: 'decision', accept: false });
+ g = applyAction(g, f.owner, {
+ type: 'move',
+ from: 'arrakeen:10',
+ territory: 'hagga_basin',
+ sector: 12,
+ amount: 5,
+ });
+ const original = nexusGuildCunningReload(g);
+ g = applyAction(g, f.owner, nexusGuildCunningRequest(f, g));
+ assert.equal(g.response?.kind, 'nexusGuildCunning');
+ const response = structuredClone(g.response);
+ g = applyAction(g, f.target, {
+ type: 'card',
+ card: worthless.id,
+ mode: 'cancel',
+ });
+ assert.equal(g.response?.kind, 'worthlessKarama');
+ assert.equal(g.pendingKarama?.use.kind, 'cancel');
+ if (g.pendingKarama?.use.kind === 'cancel')
+ assert.deepEqual(g.pendingKarama.use.response, response);
+ assert.equal(g.discard.filter((c) => c.id === worthless.id).length, 1);
+ stable(g);
+ return { f, g, original, worthless, karama };
+}
+
+void test('Guild Cunning survives an actual paid BG Worthless cancellation and its printed Karama counter with exact original movement and custody', () => {
+ for (const counter of [false, true]) {
+ const { f, original, worthless, karama, g: pending } = nested();
+ let g = counter
+ ? applyAction(nexusGuildCunningReload(pending), f.observer, {
+ type: 'card',
+ card: karama.id,
+ mode: 'cancel',
+ })
+ : nexusGuildCunningAllow(nexusGuildCunningReload(pending));
+ assert.equal(
+ g.nexusGuildCunningHistory!.at(-1)!.stage,
+ counter ? 'secondShipment' : 'canceled',
+ );
+ assert.equal(g.nexusCards!.cards!.hands[f.owner], null);
+ assert.equal(
+ g.nexusCards!.cards!.discard.filter((c) => c === 'guild').length,
+ 1,
+ );
+ assert.deepEqual(g.players[0].forces, original.players[0].forces);
+ assert.equal(g.players[0].spice, original.players[0].spice);
+ assert.equal(g.players[0].moved, 1);
+ assert.equal(g.players[0].shipped, true);
+ assert.equal(g.discard.filter((c) => c.id === worthless.id).length, 1);
+ assert.equal(
+ g.discard.filter((c) => c.id === karama.id).length,
+ counter ? 1 : 0,
+ );
+ assert.equal(g.pendingKarama, null);
+ assert.equal(g.active === f.owner, counter);
+ assert.equal(g.movementRemaining!.includes(f.owner), counter);
+ stable(g);
+ if (counter) {
+ g = applyAction(g, f.owner, {
+ type: 'ship',
+ territory: 'carthag',
+ sector: 11,
+ amount: 2,
+ });
+ if (g.decision?.kind === 'guildShipment')
+ g = applyAction(g, f.owner, { type: 'decision', allow: true });
+ if (g.decision?.kind === 'advisor')
+ g = applyAction(g, f.target, { type: 'decision', accept: false });
+ g = nexusGuildCunningAllow(g);
+ assert.equal(g.players[0].forces['carthag:11'], 2);
+ assert.equal(g.players[0].spice, original.players[0].spice - 1);
+ assert.equal(g.nexusGuildCunningHistory!.at(-1)!.stage, 'extraMove');
+ stable(g);
+ }
+ }
+});
+
+void test('nested Guild cancellation rejects altered parent, last marker and saved native source without mutation', () => {
+ const { f, g: pending, karama } = nested();
+ const changes: ((g: Game) => void)[] = [
+ (g) => {
+ g.nexusGuildCunningHistory![0].parent = '[]';
+ },
+ (g) => {
+ g.nexusGuildCunningLast!.stage = 'secondShipment';
+ },
+ (g) => {
+ delete g.nexusGuildCunningLast;
+ },
+ (g) => {
+ delete g.nexusGuildCunningHistory;
+ },
+ (g) => {
+ if (g.pendingKarama!.use.kind === 'cancel')
+ g.pendingKarama!.use.response.intent = 'old';
+ },
+ (g) => {
+ if (g.pendingKarama!.use.kind === 'cancel')
+ g.pendingKarama!.use.response.owner = f.observer;
+ },
+ (g) => {
+ g.players[0].moved = 0;
+ },
+ (g) => {
+ g.movementRemaining = g.movementRemaining!.filter((id) => id !== f.owner);
+ },
+ ];
+ for (const change of changes) {
+ const bad = nexusGuildCunningReload(pending);
+ change(bad);
+ const original = nexusGuildCunningReload(bad);
+ for (const p of bad.players) assert.throws(() => viewGame(bad, p.id));
+ assert.throws(() => normalizeAutomaticGame(bad));
+ assert.throws(() =>
+ applyAction(bad, f.observer, {
+ type: 'card',
+ card: karama.id,
+ mode: 'cancel',
+ }),
+ );
+ assert.deepEqual(bad, original);
+ }
+});
diff --git a/tests/nexus-guild-cunning-recovery.test.ts b/tests/nexus-guild-cunning-recovery.test.ts
new file mode 100644
index 00000000..936fd95f
--- /dev/null
+++ b/tests/nexus-guild-cunning-recovery.test.ts
@@ -0,0 +1,566 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { unitStore } from './fixture-nexus-room-store';
+import {
+ normalizeAutomaticGame,
+ viewGame,
+ type Game,
+ type Action,
+} from '../game/engine';
+import type { RoomsClock } from '../db/rooms';
+import {
+ nexusGuildCunningFixture,
+ originalGuildCunningTurn,
+ nexusGuildCunningRequest,
+ nexusGuildCunningInventory,
+ holdGuildCunningCard,
+ nexusGuildCunningAllianceFixture,
+} from './fixture-nexus-guild-cunning';
+const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
+async function fixture(
+ hajr = false,
+ originalMove = true,
+ selfStop = false,
+ homeworlds = false,
+ alliance = false,
+) {
+ const store = unitStore(),
+ made = await store.rooms.createRoom(
+ 'Guild Cunning SQL',
+ 'guild',
+ false,
+ [],
+ ),
+ 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)),
+ ),
+ ids = auths.map((a) => a.playerId) as [string, string, string];
+ const f = alliance
+ ? nexusGuildCunningAllianceFixture(ids)
+ : nexusGuildCunningFixture({
+ seatIds: ids,
+ karama: true,
+ advanced: selfStop,
+ homeworlds,
+ });
+ if (alliance) f.karama = holdGuildCunningCard(f.g, f.target, 'karama').id;
+ const ownKarama = selfStop
+ ? holdGuildCunningCard(f.g, f.owner, 'karama').id
+ : undefined;
+ const card = hajr ? holdGuildCunningCard(f.g, f.owner, 'hajr').id : undefined;
+ f.g = originalGuildCunningTurn(f, originalMove);
+ 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,
+ hajr: card,
+ ownKarama,
+ };
+}
+type Fixture = Awaited>;
+const row = (f: Fixture) =>
+ f.sqlite
+ .prepare('SELECT state,version FROM rooms WHERE code = ?')
+ .get(f.code);
+async function act(f: Fixture, seat: number, action: Action) {
+ 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);
+}
+async function restored(f: Fixture, g: Game) {
+ const before = row(f),
+ rooms = f.restart();
+ for (const [i, t] of f.tokens.entries()) {
+ const auth = await rooms.authenticate(f.code, t),
+ v = await rooms.readSeatView(f.code, auth);
+ assert.deepEqual(v, viewGame(g, f.ids[i]));
+ assert.equal(Object.hasOwn(v, 'nexusGuildCunningHistory'), false);
+ if (i !== 0) assert.equal(v.nexusGuildCunning, null);
+ }
+ await rooms.continueRoomAutomatic(f.code, clock);
+ assert.deepEqual(row(f), before);
+ nexusGuildCunningInventory(g);
+}
+async function compete(f: Fixture, version: number, action: Action) {
+ 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], 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],
+ );
+}
+
+void test('SQLite restores the real second-arrival Moritani alliance reply and continues the spent Guild Nexus through Hajr once', async () => {
+ const f = await fixture(true, true, false, false, true);
+ try {
+ await act(f, 0, nexusGuildCunningRequest(f));
+ let g = await f.restart().readRoom(f.code);
+ while (g.response) {
+ const voter = g.players.find(
+ (p) => !g.response!.passed.includes(p.id),
+ )!.id;
+ g = await act(f, f.ids.indexOf(voter), { type: 'passResponse' });
+ }
+ g = await act(f, 0, {
+ type: 'ship',
+ territory: 'carthag',
+ sector: 11,
+ amount: 3,
+ });
+ assert.equal(g.pendingTerrorEntry?.stage, 'offer');
+ g = await act(f, 1, { type: 'decision', alliance: true });
+ while (g.response) {
+ const voter = g.players.find(
+ (p) => !g.response!.passed.includes(p.id),
+ )!.id;
+ g = await act(f, f.ids.indexOf(voter), { type: 'passResponse' });
+ }
+ await restored(f, g);
+ const spice = g.players[0].spice,
+ reserves = g.players[0].reserves;
+ const accepted = await act(f, 0, { 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);
+ 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);
+ await act(f, 0, { type: 'card', card: f.hajr });
+ let done = await act(f, 0, {
+ type: 'move',
+ from: 'carthag:11',
+ territory: 'hagga_basin',
+ sector: 12,
+ amount: 3,
+ });
+ assert.equal(done.players[0].forces['hagga_basin:12'], 8);
+ assert.equal(done.players[0].spice, spice);
+ assert.equal(done.players[0].reserves, reserves);
+ if (done.active === f.owner)
+ done = await act(f, 0, { type: 'endMovement' });
+ assert.notEqual(done.active, f.owner);
+ await restored(f, done);
+ } finally {
+ f.sqlite.close();
+ }
+});
+
+void test('SQLite restores self-stopped second Guild shipment with no second cost or arrival and only one Hajr movement', async () => {
+ const f = await fixture(true, false, true);
+ try {
+ await act(f, 0, nexusGuildCunningRequest(f));
+ await act(f, 1, { type: 'passResponse' });
+ const pending = await act(f, 0, {
+ type: 'ship',
+ territory: 'carthag',
+ sector: 11,
+ amount: 3,
+ });
+ assert.equal(pending.decision?.kind, 'guildShipment');
+ await restored(f, pending);
+ const stopped = await act(f, 0, {
+ type: 'card',
+ mode: 'special',
+ card: f.ownKarama,
+ });
+ assert.equal(
+ stopped.nexusGuildCunningHistory!.at(-1)!.shipment!.stage,
+ 'stopped',
+ );
+ assert.equal(stopped.players[0].spice, 17);
+ assert.equal(stopped.players[0].reserves, 15);
+ assert.equal(stopped.players[0].forces['carthag:11'], undefined);
+ assert.equal(
+ stopped.nexusCards!.cards!.discard.filter((c) => c === 'guild').length,
+ 1,
+ );
+ await restored(f, stopped);
+ const move: Action = {
+ type: 'move',
+ from: 'arrakeen:10',
+ territory: 'hagga_basin',
+ sector: 12,
+ amount: 5,
+ };
+ const before = row(f);
+ await assert.rejects(
+ f.restart().act(f.code, f.auths[0], stopped.version, move, clock),
+ );
+ assert.deepEqual(row(f), before);
+ await act(f, 0, { type: 'card', card: f.hajr });
+ const done = await act(f, 0, move);
+ assert.equal(done.players[0].spice, 17);
+ assert.equal(done.players[0].reserves, 15);
+ assert.equal(done.players[0].forces['hagga_basin:12'], 5);
+ assert.equal(done.discard.filter((c) => c.id === f.ownKarama).length, 1);
+ await restored(f, done);
+ const completed = row(f);
+ await assert.rejects(
+ f.restart().act(
+ f.code,
+ f.auths[0],
+ done.version,
+ {
+ type: 'move',
+ from: 'hagga_basin:12',
+ territory: 'arsunt',
+ sector: 12,
+ amount: 5,
+ },
+ clock,
+ ),
+ );
+ assert.deepEqual(row(f), completed);
+ } finally {
+ f.sqlite.close();
+ }
+});
+
+void test('SQLite rejects lost or changed second-shipment source, paid frame and Guild interception with zero writes', async () => {
+ const f = await fixture(false, true, true, true);
+ try {
+ await act(f, 0, nexusGuildCunningRequest(f));
+ await act(f, 1, { type: 'passResponse' });
+ const pending = await act(f, 0, {
+ type: 'ship',
+ territory: 'carthag',
+ sector: 11,
+ amount: 3,
+ homeworldSources: { 'homeworld:guild': { normal: 3, elite: 0 } },
+ });
+ assert.equal(pending.decision?.kind, 'guildShipment');
+ await restored(f, pending);
+ for (const edit of [
+ (g: Game) => {
+ delete g.pendingShipment!.guildNexusEvent;
+ },
+ (g: Game) => {
+ g.pendingShipment!.guildNexusEvent = 'other';
+ },
+ (g: Game) => {
+ g.pendingShipment!.amount = 2;
+ },
+ (g: Game) => {
+ g.pendingShipment!.cost = 1;
+ },
+ (g: Game) => {
+ g.pendingShipment!.homeworldSources!['homeworld:guild'].normal = 2;
+ },
+ (g: Game) => {
+ g.pendingShipment!.territory = 'arrakeen';
+ g.pendingShipment!.sector = 10;
+ },
+ (g: Game) => {
+ g.nexusGuildCunningHistory!.at(-1)!.shipment = {
+ kind: 'reserve',
+ stage: 'pending',
+ frame: '{}',
+ };
+ },
+ (g: Game) => {
+ g.decision = null;
+ },
+ (g: Game) => {
+ if (g.decision?.kind === 'guildShipment') g.decision.amount = 2;
+ },
+ (g: Game) => {
+ if (g.decision?.kind === 'guildShipment') g.decision.shipper = f.target;
+ },
+ ]) {
+ const bad = structuredClone(pending);
+ 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: 'decision', allow: true },
+ clock,
+ ),
+ );
+ assert.deepEqual(row(f), before);
+ assert.equal(f.writes.length, 0);
+ }
+ } finally {
+ f.sqlite.close();
+ }
+});
+void test('SQLite restores the first Guild turn and lost Cunning declaration then commits exactly one raced second shipment', async () => {
+ const f = await fixture();
+ try {
+ const other = await f.rooms.createRoom(
+ 'Unrelated original room',
+ 'atreides',
+ false,
+ [],
+ ),
+ otherBefore = f.sqlite
+ .prepare('SELECT state,version FROM rooms WHERE code = ?')
+ .get(other.view.code);
+ const pending = await act(f, 0, nexusGuildCunningRequest(f));
+ await restored(f, pending);
+ assert.equal(pending.players[0].spice, 17);
+ assert.equal(pending.players[0].moved, 1);
+ assert.equal(pending.players[0].forces['hagga_basin:12'], 5);
+ const allowed = await act(f, 1, { type: 'passResponse' });
+ await restored(f, allowed);
+ f.writes.length = 0;
+ const shipment: Action = {
+ type: 'ship',
+ territory: 'carthag',
+ sector: 11,
+ amount: 3,
+ };
+ await compete(f, allowed.version, shipment);
+ const done = await f.restart().readRoom(f.code);
+ assert.equal(done.players[0].spice, 15);
+ assert.equal(done.players[0].reserves, 12);
+ assert.equal(done.players[0].forces['carthag:11'], 3);
+ assert.equal(done.players[0].moved, 1);
+ assert.equal(
+ done.nexusCards!.cards!.discard.filter((c) => c === 'guild').length,
+ 1,
+ );
+ await restored(f, done);
+ const before = row(f);
+ await assert.rejects(
+ f.restart().act(f.code, f.auths[0], done.version, shipment, clock),
+ );
+ assert.deepEqual(row(f), before);
+ assert.deepEqual(
+ f.sqlite
+ .prepare('SELECT state,version FROM rooms WHERE code = ?')
+ .get(other.view.code),
+ otherBefore,
+ );
+ } finally {
+ f.sqlite.close();
+ }
+});
+void test('SQLite canceled Guild Cunning preserves the already moved army and first payment without granting another turn', async () => {
+ const f = await fixture();
+ try {
+ await act(f, 0, nexusGuildCunningRequest(f));
+ const canceled = await act(f, 1, {
+ type: 'card',
+ mode: 'cancel',
+ card: f.karama,
+ });
+ assert.equal(canceled.players[0].spice, 17);
+ assert.equal(canceled.players[0].reserves, 15);
+ assert.equal(canceled.players[0].forces['hagga_basin:12'], 5);
+ assert.equal(canceled.players[0].moved, 1);
+ assert.equal(canceled.movementRemaining!.includes(f.owner), false);
+ assert.equal(canceled.discard.filter((c) => c.id === f.karama).length, 1);
+ await restored(f, canceled);
+ const before = row(f);
+ await assert.rejects(
+ f
+ .restart()
+ .act(
+ f.code,
+ f.auths[0],
+ canceled.version,
+ nexusGuildCunningRequest(f),
+ clock,
+ ),
+ );
+ assert.deepEqual(row(f), before);
+ } finally {
+ f.sqlite.close();
+ }
+});
+void test('SQLite late Hajr restores one extra move after the second shipment without replaying either shipment', async () => {
+ const f = await fixture(true);
+ try {
+ await act(f, 0, nexusGuildCunningRequest(f));
+ await act(f, 1, { type: 'passResponse' });
+ const shipped = await act(f, 0, {
+ type: 'ship',
+ territory: 'carthag',
+ sector: 11,
+ amount: 3,
+ });
+ await restored(f, shipped);
+ const extra = await act(f, 0, { type: 'card', card: f.hajr });
+ await restored(f, extra);
+ const done = await act(f, 0, {
+ type: 'move',
+ from: 'carthag:11',
+ territory: 'hagga_basin',
+ sector: 12,
+ amount: 3,
+ });
+ assert.equal(done.players[0].spice, 15);
+ assert.equal(done.players[0].reserves, 12);
+ assert.equal(done.players[0].forces['hagga_basin:12'], 8);
+ assert.equal(done.players[0].moved, 2);
+ assert.equal(done.discard.filter((c) => c.id === f.hajr).length, 1);
+ await restored(f, done);
+ } finally {
+ f.sqlite.close();
+ }
+});
+void test('SQLite changed Guild Cunning history or original movement proof cannot project, normalize or write a continuation', async () => {
+ const f = await fixture();
+ try {
+ const pending = await act(f, 0, nexusGuildCunningRequest(f));
+ for (const edit of [
+ (g: Game) => {
+ delete g.nexusGuildCunningHistory;
+ },
+ (g: Game) => {
+ g.nexusGuildCunningHistory![0].signature = 'changed';
+ },
+ (g: Game) => {
+ g.response = null;
+ },
+ (g: Game) => {
+ g.players[0].moved = 0;
+ },
+ (g: Game) => {
+ g.players[0].shipped = false;
+ },
+ (g: Game) => {
+ g.response!.owner = f.target;
+ },
+ ]) {
+ const bad = structuredClone(pending);
+ 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[1],
+ bad.version,
+ { type: 'passResponse' },
+ clock,
+ ),
+ );
+ assert.deepEqual(row(f), before);
+ assert.equal(f.writes.length, 0);
+ }
+ } finally {
+ f.sqlite.close();
+ }
+});
+
+void test('SQLite restores a declined second shipment and races exactly one Hajr move after the original movement was declined', async () => {
+ const f = await fixture(true, false);
+ try {
+ await act(f, 0, nexusGuildCunningRequest(f));
+ const allowed = await act(f, 1, { type: 'passResponse' });
+ const skip: Action = {
+ type: 'nexusGuildSkipShipment',
+ event: viewGame(allowed, f.owner).nexusGuildCunning!.active!.event,
+ };
+ const skipped = await act(f, 0, skip);
+ assert.equal(skipped.players[0].spice, 17);
+ assert.equal(skipped.players[0].reserves, 15);
+ assert.equal(skipped.players[0].moved, 0);
+ assert.equal(skipped.players[0].shipped, true);
+ await restored(f, skipped);
+ const move: Action = {
+ type: 'move',
+ from: 'arrakeen:10',
+ territory: 'hagga_basin',
+ sector: 12,
+ amount: 5,
+ };
+ const before = row(f);
+ for (const action of [move, skip])
+ await assert.rejects(
+ f.restart().act(f.code, f.auths[0], skipped.version, action, clock),
+ );
+ assert.deepEqual(row(f), before);
+ const extra = await act(f, 0, { type: 'card', card: f.hajr });
+ await restored(f, extra);
+ f.writes.length = 0;
+ await compete(f, extra.version, move);
+ const done = await f.restart().readRoom(f.code);
+ assert.equal(done.players[0].forces['hagga_basin:12'], 5);
+ assert.equal(done.players[0].spice, 17);
+ assert.equal(done.players[0].reserves, 15);
+ assert.equal(done.discard.filter((c) => c.id === f.hajr).length, 1);
+ await restored(f, done);
+ const completed = row(f);
+ await assert.rejects(
+ f.restart().act(
+ f.code,
+ f.auths[0],
+ done.version,
+ {
+ type: 'move',
+ from: 'hagga_basin:12',
+ territory: 'arsunt',
+ sector: 12,
+ amount: 5,
+ },
+ clock,
+ ),
+ );
+ assert.deepEqual(row(f), completed);
+ } finally {
+ f.sqlite.close();
+ }
+});
diff --git a/tests/nexus-guild-cunning.test.ts b/tests/nexus-guild-cunning.test.ts
new file mode 100644
index 00000000..e30b3582
--- /dev/null
+++ b/tests/nexus-guild-cunning.test.ts
@@ -0,0 +1,251 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ createNexusGuildCunning,
+ validateNexusGuildCunning,
+ nexusGuildCunningMoves,
+ type NexusGuildCunningContext,
+ type NexusGuildCunningReceipt,
+} from '../game/nexus-guild-cunning';
+
+const context = {
+ turn: 3,
+ phase: 5,
+ active: 'g',
+ players: [
+ { id: 'a', faction: 'atreides' as const },
+ { id: 'g', faction: 'guild' as const },
+ { id: 'f', faction: 'fremen' as const },
+ ],
+};
+const clone = (value: T): T => JSON.parse(JSON.stringify(value));
+const make = (moved = 0, hajrUsed = false, shipped = false) =>
+ createNexusGuildCunning(context, 'g', { shipped, moved, hajrUsed });
+
+void test('Guild Cunning records the ended original combined turn whether its shipment and movement were used or declined', () => {
+ for (const shipped of [false, true])
+ for (const hajrUsed of [false, true])
+ for (let moved = 0; moved <= (hajrUsed ? 2 : 1); moved++) {
+ const before = { shipped, moved, hajrUsed },
+ original = clone(before);
+ const receipt = createNexusGuildCunning(context, 'g', before);
+ assert.equal(
+ receipt.event,
+ JSON.stringify(['nexusGuildCunning', 3, 'g']),
+ );
+ assert.deepEqual(receipt.before, original);
+ assert.deepEqual(before, original);
+ assert.notEqual(receipt.before, before);
+ assert.equal(receipt.mode, 'cunning');
+ validateNexusGuildCunning(context, clone(receipt));
+ }
+});
+
+void test('second shipment never supplies an ordinary move, including a declined first movement', () => {
+ for (const moved of [0, 1]) {
+ const receipt = make(moved, false, true);
+ assert.equal(nexusGuildCunningMoves(receipt, false), 0);
+ assert.throws(() => nexusGuildCunningMoves(receipt, false, 1));
+ assert.equal(
+ nexusGuildCunningMoves(receipt, true),
+ 1,
+ 'later Hajr grants exactly its extra slot',
+ );
+ assert.equal(nexusGuildCunningMoves(receipt, true, 1), 0);
+ }
+});
+
+void test('previously played Hajr keeps an unused extra slot after decline, but an already consumed Hajr grants no third move', () => {
+ for (const moved of [0, 1]) {
+ const receipt = make(moved, true);
+ assert.equal(nexusGuildCunningMoves(receipt, true), 1);
+ assert.equal(nexusGuildCunningMoves(receipt, true, 1), 0);
+ assert.throws(() => nexusGuildCunningMoves(receipt, false));
+ }
+ const consumed = make(2, true);
+ assert.equal(nexusGuildCunningMoves(consumed, true), 0);
+ assert.throws(() => nexusGuildCunningMoves(consumed, true, 1));
+ for (const count of [-1, 0.5, 2, NaN, Infinity])
+ assert.throws(() => nexusGuildCunningMoves(make(1, true), true, count));
+});
+
+void test('only native unallied Guild can create the receipt at its own phase-five boundary', () => {
+ const before = { shipped: false, moved: 0, hajrUsed: false };
+ for (const phase of [0, 4, 6, 8])
+ assert.throws(() =>
+ createNexusGuildCunning({ ...context, phase }, 'g', before),
+ );
+ assert.throws(() =>
+ createNexusGuildCunning({ ...context, active: 'a' }, 'g', before),
+ );
+ assert.throws(() =>
+ createNexusGuildCunning({ ...context, active: 'a' }, 'a', before),
+ );
+ assert.throws(() =>
+ createNexusGuildCunning(
+ {
+ ...context,
+ players: context.players.map((p) => ({
+ ...p,
+ ally: p.id === 'g' ? 'a' : null,
+ })),
+ },
+ 'g',
+ before,
+ ),
+ );
+ for (const bad of [
+ { ...before, moved: 2 },
+ { ...before, moved: 3, hajrUsed: true },
+ { ...before, moved: -1 },
+ { ...before, moved: 0.5 },
+ { ...before, moved: NaN },
+ { ...before, shipped: 0 },
+ { ...before, hajrUsed: 1 },
+ { ...before, extra: 1 },
+ ])
+ assert.throws(() =>
+ createNexusGuildCunning(context, 'g', bad as typeof before),
+ );
+});
+
+void test('completed original receipts survive later alliances, turns and resource changes without reading private fields', () => {
+ const receipt = make(1, true, true);
+ const trap = () => {
+ throw new Error('private field was read');
+ };
+ const later: NexusGuildCunningContext = {
+ turn: 7,
+ players: context.players.map((p) => ({
+ ...p,
+ get ally() {
+ return trap();
+ },
+ get hand() {
+ return trap();
+ },
+ get spice() {
+ return trap();
+ },
+ get shipped() {
+ return trap();
+ },
+ get moved() {
+ return trap();
+ },
+ get reserves() {
+ return trap();
+ },
+ })),
+ };
+ validateNexusGuildCunning(later, receipt);
+ assert.equal(nexusGuildCunningMoves(receipt, true), 1);
+ assert.throws(() =>
+ validateNexusGuildCunning({ ...later, turn: 2 }, receipt),
+ );
+ assert.throws(() =>
+ validateNexusGuildCunning(
+ { ...later, players: later.players.slice(0, 2) },
+ receipt,
+ ),
+ );
+});
+
+void test('creation reads only public roster, owner alliance, timing and the supplied original allowance', () => {
+ const trap = () => {
+ throw new Error('private field was read');
+ };
+ const publicContext = {
+ ...context,
+ players: context.players.map((p) =>
+ Object.freeze({
+ ...p,
+ ally: null,
+ get hand() {
+ return trap();
+ },
+ get spice() {
+ return trap();
+ },
+ get forces() {
+ return trap();
+ },
+ }),
+ ),
+ };
+ Object.freeze(publicContext.players);
+ Object.freeze(publicContext);
+ const before = Object.freeze({ shipped: true, moved: 1, hajrUsed: true });
+ const receipt = createNexusGuildCunning(publicContext, 'g', before);
+ assert.deepEqual(Object.keys(receipt).sort(), [
+ 'before',
+ 'event',
+ 'mode',
+ 'owner',
+ 'phase',
+ 'roster',
+ 'signature',
+ 'turn',
+ 'version',
+ ]);
+ assert.equal(JSON.stringify(receipt).includes('hand'), false);
+ assert.equal(nexusGuildCunningMoves(Object.freeze(receipt), true, 1), 0);
+});
+
+void test('changed original facts, foreign fields, deleted proofs and malformed roster identities reject immutably', () => {
+ const receipt = make(1, true, true);
+ const mutations: ((r: NexusGuildCunningReceipt) => void)[] = [
+ (r) => {
+ r.before.moved = 0;
+ },
+ (r) => {
+ r.before.shipped = false;
+ },
+ (r) => {
+ r.before.hajrUsed = false;
+ },
+ (r) => {
+ r.event = 'stale';
+ },
+ (r) => {
+ r.owner = 'a';
+ },
+ (r) => {
+ r.turn = 2;
+ },
+ (r) => {
+ r.signature = '';
+ },
+ (r) => {
+ r.roster.reverse();
+ },
+ (r) => {
+ r.roster[0].id = 'changed';
+ },
+ (r) => {
+ delete (r as Partial).before;
+ },
+ (r) => {
+ Object.assign(r, { stage: 'secondShipment' });
+ },
+ ];
+ for (const mutate of mutations) {
+ const bad = clone(receipt);
+ mutate(bad);
+ const original = clone(bad);
+ assert.throws(() => validateNexusGuildCunning(context, bad));
+ assert.deepEqual(bad, original);
+ }
+ for (const players of [
+ [...context.players, context.players[0]],
+ context.players.map((p) => ({ ...p, id: 'same' })),
+ context.players.map((p) => ({ ...p, faction: 'guild' as const })),
+ context.players.map((p) => ({
+ ...p,
+ id: p.id === 'a' ? '__proto__' : p.id,
+ })),
+ ])
+ assert.throws(() =>
+ validateNexusGuildCunning({ ...context, players }, receipt),
+ );
+});