diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml
index 1a844a39..ffbb1c53 100644
--- a/.github/workflows/container.yml
+++ b/.github/workflows/container.yml
@@ -29,6 +29,8 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
+ build-args: |
+ BUILD_REVISION=${{ github.sha }}
load: true
tags: dune:verify
cache-from: type=gha
diff --git a/Dockerfile b/Dockerfile
index 00b59f35..7b053bf7 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -4,6 +4,8 @@ ENV CI=true WRANGLER_SEND_METRICS=false GOMEMLIMIT=2GiB GOMAXPROCS=2
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
+ARG BUILD_REVISION
+ENV BUILD_REVISION=${BUILD_REVISION}
RUN npm run build
FROM node:22-bookworm-slim AS runtime
@@ -18,6 +20,7 @@ COPY package.json ./
COPY tools/wrangler.local.json ./tools/wrangler.local.json
COPY tools/admin-access.mjs ./tools/admin-access.mjs
COPY deploy/container-entrypoint.sh ./deploy/container-entrypoint.sh
+COPY deploy/bootstrap-admin.mjs ./deploy/bootstrap-admin.mjs
COPY deploy/serve.mjs ./deploy/serve.mjs
RUN mkdir -p /data /app/.wrangler && chown node:node /data /app/.wrangler
USER node
diff --git a/app/globals.css b/app/globals.css
index 0a88451b..4245122b 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -99,6 +99,11 @@ a:focus-visible {
font-size: 14px;
color: #c6c6b9;
}
+.build-revision {
+ color: var(--muted-foreground);
+ font-size: 12px;
+ white-space: nowrap;
+}
.masthead-right a {
display: flex;
gap: 9px;
diff --git a/app/page.tsx b/app/page.tsx
index 5a4a5570..f0d50948 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -10,6 +10,7 @@ import {
SeatHandoverClaim,
} from '@/components/seat-handover';
import { HANDOVER_CLAIM_STORAGE_KEY } from '@/lib/seat-handover';
+import { buildRevision, buildRevisionLabel } from '@/lib/build-revision';
import { SeatAiDelegation } from '@/components/seat-ai-delegation';
import {
SeatRecoverySetup,
@@ -1190,6 +1191,7 @@ export default function Home() {
Unofficial fan implementation
Administration
+ Git revision: {buildRevisionLabel}
Dune · A game of conquest, diplomacy & betrayal
diff --git a/components/game-table.tsx b/components/game-table.tsx
index 390a8620..527b917f 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -30,6 +30,8 @@ import { DiscoveryEntryDecision } from './discovery-entry';
import { DiscoveryStormDecision } from './discovery-storm';
import { NexusChoamTrade } from './nexus-choam-trade';
import { NexusMoritaniBetrayal } from './nexus-moritani-betrayal';
+import { buildRevision, buildRevisionLabel } from '@/lib/build-revision';
+import { NexusEcazBetrayal } from './nexus-ecaz-betrayal';
import { NexusTleilaxu } from './nexus-tleilaxu';
import { NexusSuboids } from './nexus-suboids';
import { NexusAdvisors } from './nexus-advisors';
@@ -939,6 +941,9 @@ export function GameTable({
}} />
{g.status === 'lobby' ? 'Gathering players' : `Turn ${g.turn} / 10`}
+
+ Git revision: {buildRevisionLabel}
+
{
@@ -1583,6 +1588,7 @@ export function GameTable({
+
@@ -1996,6 +2002,8 @@ export function GameTable({
kwisatz: 'Kwisatz Haderach battle protection',
guildTiming: 'Guild shipment and movement timing',
nexusGuildCunning: 'Guild Cunning second shipment',
+ nexusFremenCunning: 'Fremen Cunning remote worm ride',
+ nexusEcazBetrayal: 'Ecaz Nexus forced ally return',
atreidesAuction: 'Atreides auction foresight',
atreidesSpice: 'Atreides spice foresight',
stormPeek: 'Fremen storm foresight',
@@ -2165,11 +2173,19 @@ export function GameTable({
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.kind === 'nexusFremenCunning' &&
+ The Fremen Nexus card was spent for a remote ride after this Nexus. Karama can prevent that ride, but the original worm still resolves.
+
}
+ {g.response.kind === 'nexusEcazBetrayal' &&
+ Ecaz Nexus Betrayal was spent to return Ecaz’s ally’s forces from {territory(g.response.location!).name}.
+ Karama can prevent that return; the card stays spent.
+
}
{g.response.intent &&
g.response.kind !== 'nexusGuildCunning' &&
g.response.source !== 'ambassador' &&
g.response.kind !== 'nexusAdvisorFlip' &&
g.response.kind !== 'nexusSardaukar' &&
+ g.response.kind !== 'nexusEcazBetrayal' &&
g.response.kind !== 'faceDancerReplacement' && (
{g.response.intent}
)}
@@ -2392,6 +2408,10 @@ export function GameTable({
? 'Enter a Discovery location'
: g.decision.kind === 'ecologicalStorm'
? 'Ecological Testing Station'
+ : g.decision.kind === 'nexusFremenCunningOffer'
+ ? 'Empty worm: Fremen Cunning'
+ : g.decision.kind === 'nexusFremenCunningRide'
+ ? 'Fremen Cunning remote ride'
: g
.decision
.kind ===
@@ -3170,6 +3190,72 @@ export function GameTable({
accept: false,
})}
>
+ ) : g.decision.kind === 'nexusFremenCunningOffer' ? (
+ <>
+ An empty sandworm appearance can trigger Fremen Cunning.
+ If you hold your own Fremen Nexus Card, spend it now to arrange a remote
+ ride from one occupied desert territory after this Nexus. The worm still resolves normally.
+ {g.nexusCards?.card === 'fremen' && actionButton('Spend Fremen Cunning', {
+ type: 'decision', event: g.decision.event, accept: true,
+ })}
+ {actionButton('Continue without Cunning', {
+ type: 'decision', event: g.decision.event, accept: false,
+ })}
+ >
+ ) : g.decision.kind === 'nexusFremenCunningRide' ? (
+ <>
+ The Nexus has ended. Choose one occupied desert territory,
+ typed forces and a legal destination for the extra ride. The original
+ worm appearance has already resolved; this does not consume normal movement.
+ Desert source
+ {
+ setSource(e.target.value);
+ setRideForces({});
+ setEliteForces({});
+ }}>
+ Choose source
+ {[...new Set(Object.entries(me.forces)
+ .filter(([key, count]) => count > 0 &&
+ splitLocation(key).sector !== g.storm &&
+ TERRITORIES.some(t => t.id === splitLocation(key).territory && t.type === 'sand'))
+ .map(([key]) => splitLocation(key).territory))].map(id =>
+ {combatName(id)} )}
+
+
+ {Object.entries(me.forces).filter(([key, count]) =>
+ count > 0 && splitLocation(key).territory === source &&
+ splitLocation(key).sector !== g.storm).map(([key, count]) =>
+
+ Sector {splitLocation(key).sector} · {count} forces
+ setRideForces({
+ ...rideForces, [key]: Number(e.target.value),
+ })} />
+
+ {me.elites && (
+ setEliteForces({ ...eliteForces, [key]: n })} />
+ )}
+
)}
+ {destination}
+ {actionButton('Ride remotely', {
+ type: 'decision', event: g.decision.event, accept: true,
+ source, territory: selected, sector,
+ forces: Object.fromEntries(Object.entries(rideForces)
+ .filter(([key, count]) => count > 0 && splitLocation(key).territory === source)
+ .map(([key, count]) => [key, {
+ normal: count - (eliteForces[key] ?? 0),
+ elite: eliteForces[key] ?? 0,
+ }])),
+ }, !source || !Object.entries(rideForces).some(([key, count]) =>
+ count > 0 && splitLocation(key).territory === source))}
+ {actionButton('Decline remote ride', {
+ type: 'decision', event: g.decision.event, accept: false,
+ })}
+ >
) : g.decision.kind === 'wormRide' ? (
<>
diff --git a/components/nexus-ecaz-betrayal.tsx b/components/nexus-ecaz-betrayal.tsx
new file mode 100644
index 00000000..51b173ef
--- /dev/null
+++ b/components/nexus-ecaz-betrayal.tsx
@@ -0,0 +1,40 @@
+'use client';
+
+import { useState } from 'react';
+import { territory as boardTerritory } from '@/game/board';
+import type { Action, GameView } from '@/game/engine';
+import { nexusCardReference } from '@/game/nexus-card-reference';
+import { ecazBetrayalAction } from '@/game/nexus-ecaz-betrayal-options';
+import { Button } from './ui/button';
+
+type Props = { game: GameView; act: (action: Action) => void; busy: boolean };
+
+export function NexusEcazBetrayal({ game, act, busy }: Props) {
+ const [selected, select] = useState('');
+ const offer = game.nexusEcazBetrayal;
+ if (!offer || game.nexusCards?.card !== 'ecaz' ||
+ !game.players.some(player => player.id === game.me)) return null;
+ const choice = offer.territories.find(candidate => candidate.territory === selected) ?? offer.territories[0];
+ const action = choice ? ecazBetrayalAction(game, choice.territory) : null;
+ const ally = game.players.find(player => player.id === offer.ally);
+ return (
+
+ Ecaz Nexus · Betrayal
+ {nexusCardReference('ecaz').betrayal}
+ {ally && Ecaz’s ally: {ally.name}. Their entire physical group in the selected territory returns to reserves after the Karama response.
}
+ {offer.territories.length > 1 &&
+ Shared territory
+ select(event.target.value)}>
+ {offer.territories.map(candidate => {boardTerritory(candidate.territory).name} )}
+
+ }
+ {choice && Selected: {boardTerritory(choice.territory).name} · {choice.total.normal} normal · {choice.total.elite} elite forces
}
+ {offer.blocked && {offer.blocked}
}
+ { if (!busy && action) act(action); }}>
+ Use Betrayal: return ally’s forces
+
+
+ );
+}
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 8e1232ea..c178d141 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -6,6 +6,30 @@ Expansion starts and full rules/publication acceptance remain gated.**
## Current checkpoint and work
+The bounded [Fremen Nexus Cunning](NEXUS_FREMEN_RULES.md#connected-native-cunning-path)
+prototype connects an initially empty worm's public offer, one spent private
+card, a Karama response, a typed remote ride after the Nexus, human controls
+and minimal legal AI in classic-faction Nexus-only games. Basic/Advanced
+natural continuation, an additional-worm placement and an actual Advanced
+special-Karama summon retain their physical parent, response and native-then-
+remote ride order across JSON/isolated SQLite recovery and duplicate CAS.
+Natural and summoned offer/ride browser surfaces were exercised. Review found
+and fixed an inherited-key force-custody bypass. A second Cunning offer
+inside an interrupted control, Great Maker, combined modules, Secret Ally
+protection and complete Nexus games remain gated.
+
+The bounded [Ecaz Nexus Betrayal](NEXUS_ECAZ_RULES.md#bounded-betrayal-runtime)
+path lets a card holder select one public territory shared by Ecaz and its
+ally before the first Shipment and Movement turn advances. One spent card,
+a provisional Karama response and exact typed ally-force return are wired
+through human controls, legal bot action, JSON and in-memory SQL continuation,
+and focused gameplay, race and restart tests. A local browser fixture rendered
+the holder-only offer and rival Karama notice without writing a room. Secret
+Ally inquiry, Duke Cunning and combined modes remain gated. The main page
+labels its build's Git commit in the lobby and active-table masthead; local
+Git HEAD does not represent uncommitted work. The main-branch container
+workflow publishes an image but does not deploy the live site automatically.
+
Current work adds [discussion moderation](ADMIN_DISCUSSION.md): owners/operators
can mute or unmute new public/private messages from a human seat. History,
incoming messages, game state and credentials remain saved; the setting follows
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index 29e8f9d1..4bab24df 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,47 @@
# Dune implementation status
+## 26 September 2026 — Bounded Ecaz Nexus Betrayal and build revision
+
+The [Ecaz source contract](NEXUS_ECAZ_RULES.md#bounded-betrayal-runtime)
+now has a narrow Betrayal path at the opening of Shipment and Movement.
+The unallied card holder chooses a public territory shared by Ecaz and its
+reciprocal ally. One physical Ecaz card is spent before a provisional Karama
+response; allowance returns the ally's entire typed group from all sectors
+to reserves, while cancellation preserves its board forces. The native
+shipment and movement queue is unchanged. Pure quote, real turn-two Nexus,
+bot ordering, privacy, rejection, JSON continuation and in-memory SQL
+duplicate-CAS/restart regressions exercise this bounded path. A local browser
+fixture rendered the owner-only shared-territory selector and rival public
+Karama notice; no room was written. The homepage revision was visually
+verified on local desktop and mobile. Broader combined-module acceptance and
+other Ecaz Nexus panels remain separate.
+
+The landing lobby and active-table masthead now show the build's Git commit.
+Vite bakes a checkout HEAD or validated `BUILD_REVISION`; the container
+workflow passes its GitHub commit to Docker before building. A source tree
+with uncommitted edits still displays its committed HEAD, not those edits.
+
+## 26 September 2026 — Bounded Fremen Nexus Cunning prototype
+
+The [Fremen Nexus source contract](NEXUS_FREMEN_RULES.md#connected-native-cunning-path)
+now has a native Basic/Advanced path for initially empty natural and accepted
+additional worm appearances, plus an Advanced Fremen special-Karama summoned
+appearance in classic-faction Nexus-only games. A uniform public offer does
+not disclose the privately held Fremen card. Accepted use spends it once,
+preserves the original worm and permits
+one source-labelled desert ride after its Nexus, moves exact normal/Fedaykin
+counters, and resumes native arrival children. Summons keep a separate
+server-issued parent event through the saved original Spice Blow. A Karama
+response provisionally cancels only the remote ride. JSON and in-memory
+SQLite continuations, duplicate-offer CAS races for natural and summoned
+appearances, owner-only controls, minimal legal bot participation and focused
+checks cover this bounded path. Independent review exposed an inherited-key
+force creation bug that was reproduced and fixed. Browser QA exercised natural
+and summoned offer/response/ride controls from genuine projected views with
+isolated GET interception and blocked POST; no room was mutated. Full module
+games, second Cunning offers during interrupted controls and Great Maker,
+combined modules and the absent-faction protection remain gated.
+
## 26 September 2026 — Administrative discussion moderation prototype
[Discussion controls](ADMIN_DISCUSSION.md) connect human-seat mute/unmute to the
diff --git a/docs/NEXUS_ECAZ_RULES.md b/docs/NEXUS_ECAZ_RULES.md
index 05d8cf87..4af43de5 100644
--- a/docs/NEXUS_ECAZ_RULES.md
+++ b/docs/NEXUS_ECAZ_RULES.md
@@ -47,3 +47,36 @@ Read the target's completed current hand. Finish an already pending Harkonnen dr
Implement the common, source-clear contract as a pure boolean intersection of **explicitly supplied queried leader IDs** with an ordinary held-traitor snapshot. Let the engine select the agreed leader scope and audience. Bind the accepted event, original seats/factions, turn/phase, chosen target, scope and boolean in a completed receipt; keep private source evidence server-side if retained. Discard the one real Ecaz Nexus card in the same accepted transaction. Validate current global custody separately from historical answers. Public offers can be built entirely from roster, timing/continuation state and the owner's card, without reading target secrets.
Meaningful regressions should cover positive and negative answers with equal public hand sizes; dead/revived native leaders; native captive versus foreign captive/Ghola cases for the explicitly selected scope; Duke and Hero identities; Tleilaxu role separation; Harkonnen exchange before/after inquiry; stale/replayed/concurrent play; JSON/SQL historical persistence; and exact audience projection without counts or matching identities. Until scope and audience are selected, those unresolved branches must not be presented as fully source-verified behavior.
+
+## Bounded Betrayal runtime
+
+The printed Betrayal is a **separate panel** from the Secret Ally inquiry above.
+With Ecaz seated, an unallied holder of the one physical Ecaz Nexus card may
+choose one printed Arrakis territory occupied by both Ecaz and its reciprocal
+ally. The ally returns its **entire** normal/starred group from every sector of
+that territory to reserves; Ecaz's forces and the ally's other territories stay.
+This is not a shipment, movement, combat loss or revival. The holder's private
+offer contains only public positions and typed public force counts; rivals do
+not see the held card. Acceptance spends the card once and opens a public
+Karama response. Allowance returns the quoted group; cancellation leaves all
+board forces unchanged and the Nexus card spent. This response classification
+is an explicit **provisional composition**, not a GF9 ruling granting Karama
+blanket jurisdiction over Nexus effects.
+
+The first implementation window is deliberately narrow: phase 5 while its
+first active player is still current, the original movement queue is intact,
+no pending decision/response and no shipment or movement has been used. It does
+not assert that this is the only possible reading of “before Shipment and
+Movement.” The physical Ecaz alliance and co-occupation must be current.
+An automated eligible holder is scheduled before an earlier-seated movement
+bot closes this opening; a human holder must declare before that first turn
+advances. The game does not pause all seats to reveal a hidden card.
+Only the classic factions plus Ecaz/Moritani and the Nexus module are
+admitted; advisors, hidden No-Fields, Homeworlds, Discoveries, Leader Skills,
+Tech, Stronghold Cards and other combined modules remain gated. A saved
+pending snapshot binds the card's discard position, ally and exact typed board
+group through the Karama window, even if another Nexus card is discarded
+during that response. The ally's reserve counters are validated before any
+board return. Saved receipts reject replay but are not cryptographic proof of
+historical save authenticity. Ecaz Cunning's Duke override and the Secret
+Ally identity/audience questions above remain unimplemented.
diff --git a/docs/NEXUS_FREMEN_RULES.md b/docs/NEXUS_FREMEN_RULES.md
index 4e240c9a..d1fb84bf 100644
--- a/docs/NEXUS_FREMEN_RULES.md
+++ b/docs/NEXUS_FREMEN_RULES.md
@@ -1,6 +1,6 @@
# Fremen Nexus: worm and revival contract
-Primary-source and integration audit, 10 September 2026; narrow Secret Ally Revival prototype integrated later. This document does not certify the other two Fremen effects or open a release gate. The [common Nexus rules](NEXUS_CARD_RULES.md) apply. No new user question was sent.
+Primary-source and integration audit, 10 September 2026; narrow Secret Ally Revival and native Cunning prototypes integrated later. This document does not certify Fremen protection, combined-module Cunning or complete Fremen effects, and does not open a release gate. The [common Nexus rules](NEXUS_CARD_RULES.md) apply. No new user question was sent.
## Evidence
@@ -37,6 +37,19 @@ Recommended focused contracts:
Two interactions need a concrete decision before treating this as a complete feature. The card triggers at appearance while ordinary riding follows negotiations: a just-drawn card must not automatically buy a retrospective ride for an earlier empty worm. Also, ordinary Karama's printed Shai-Hulud result destroys forces at the worm, whereas the remote source is elsewhere. Do not destroy that remote source, or invent a global loss of all riding, as a shortcut for cancellation. A bounded enhanced-native cancellation policy must preserve the original empty occurrence and unrelated rides.
+### Connected native Cunning path
+
+`game/nexus-fremen-cunning.ts` captures `{event, turn, territory, origin, pile?, parent?, initiallyEmpty}` at the accepted appearance before destruction, using public `presenceAt` across players. Advisers and public No-Field markers count without revealing marker denomination. `beginWorm` offers the same Fremen decision for an initially empty appearance whether or not that seat holds its own card; the owner-only card view controls the spend button. The server, not the client, issues the event and later `{event, turn, owner}` ride authorization. The actual Fremen card moves to the Nexus discard on acceptance.
+
+The current runtime path covers natural and accepted additional worms in Basic/Advanced, plus Fremen special-Karama summoned worms in Advanced, with seated classic factions, Nexus alone and no other optional modules. The summon mints a separate server-side parent event when its physical Karama is spent; a new empty appearance binds the Cunning occurrence to that parent before devouring. A saved offer or pending ride must still belong to the active summon. A summon occurring inside another unresolved Cunning offer or ride retains its ordinary worm path without a second Cunning offer. Great Maker is not enabled for this Cunning path; Sandtrout-suppressed and first-turn ignored *drawn* worms never call the appearance hook, while a real special summon still does. `quoteFremenCunning` checks native unallied entitlement and lists typed forces in each occupied sand territory. After spending, `quoteFremenCunningRide` requires the saved event authorization and spent physical card; `validateFremenCunningSelection` requotes one source and a subset of normal/Fedaykin counters immediately before moving. The destination uses Arrakis entry, storm and occupation rules, not the desert-only source restriction. Ordinary worm destruction and the applicable Nexus happen first; `nextWormRide` schedules native rides before the remote ride afterward, without consuming normal movement. BG intrusion and territory-entry continuations use the native worm-ride path. JSON normalization and rejected-action checks cover a live offer, a spent response and the post-Nexus selection.
+
+A special summon during an existing Cunning offer or Karama response saves and
+restores that original control. When it interrupts a selected remote ride, the
+unfinished ride returns to its queue so the new Nexus and ordinary worm rides
+resolve first; no second Cunning offer is opened for that nested appearance.
+
+One Karama response may cancel only this additional remote ride after the Nexus card is spent. The originally empty appearance still destroys/places spice as usual; the remote source is not destroyed, and unrelated native rides continue. This is a **provisional enhanced-native cancellation composition**, not an explicit publisher ruling about Nexus/Karama classification. Games requiring other modules, a second Cunning offer inside an already pending Cunning control, Great Maker reserve replacement or Sandmaster collection stay gated until their own adapters/rulings are verified. The accepted receipt is server-issued but not cryptographically signed; this path does not claim malicious-save authentication.
+
## Secret Ally protection
With Fremen absent, `wormSurvival` currently proceeds directly to destruction. The protection must be committed before that destruction; it cannot revive already devoured troops, prevent removal of spice, or grant a ride. Bind it to one worm occurrence and the holder's affected physical group, including the existing No-Field reveal/capped-materialization path. Other factions remain vulnerable.
@@ -48,7 +61,7 @@ A pause offered only when the threatened player secretly holds this card would d
The card's total-of-three wording and the native alliance's ordinary free-three benefit support a **free-rate interpretation within ordinary revival accounting**, rather than three additional counters beyond the usual allowance. This is source composition, not an explicit Nexus FAQ. Preserve it as the proposed contract; do not silently apply Emperor's separate extra-return producer.
The current first path is a deliberate subset of that proposed contract: with Fremen absent, an unallied base-faction holder in Basic or Advanced Revival may spend the physical Nexus card to transfer exactly three eligible forces from Tanks to reserves at zero spice cost. It is available before any ordinary force return, with no competing expansion/module revival modifier, pending response or decision. The three returned forces consume the ordinary allowance and free-force ledger; they are **not** Emperor-style extra returns. The Advanced one-elite-per-turn cap and physical elite census apply. The server's private offer provides the eligible elite counts; the action validates its current turn and group. A signed before/after receipt and independent event markers persist through JSON saves.
-This direct transfer is safe only because the scoped seats exclude Tleilaxu income/Axlotl and Homeworld arrival continuations. It must not be generalized to those modes without using the native pending-revival pipeline. If fewer than three forces are in Tanks, if a force return already occurred, or if a higher free rate or modifier applies, the option stays unavailable rather than guessing a ruling. Protection and Cunning remain unimplemented.
+This direct transfer is safe only because the scoped seats exclude Tleilaxu income/Axlotl and Homeworld arrival continuations. It must not be generalized to those modes without using the native pending-revival pipeline. If fewer than three forces are in Tanks, if a force return already occurred, or if a higher free rate or modifier applies, the option stays unavailable rather than guessing a ruling. Protection remains unimplemented; Cunning's bounded native path above is separate.
`game/revival.ts` already computes free rate/remaining from `p.revived`, typed prices and ordinary limits. Engine `beginRevival`/`finishRevival` retains pending requests and Tleilaxu prevention/income continuations. The future combined-mode adapter should be an explicit source-labelled, phase-bound free-rate entitlement consumed by an actual normal revival request, retaining previous returns and exact free/paid normal/starred allocation. Never refund earlier paid revivals or reset `revived`/elite counters on card play or reload.
@@ -56,6 +69,8 @@ Keep these questions visible: how the three-total interacts with a higher native
Tleilaxu may be present even though Fremen is absent. `collectRevivalIncome` uses a per-player/turn free-income marker and a separate Ghola path; do not create another payment merely because a Nexus card was played. Axlotl activation and any eligible Homeworld deployment must follow the actual returned batch. Preserve the existing [revival-accounting questions](TLEILAXU_AMBASSADOR_RULES.md), exact typed caps, and signed return/arrival continuations. No leader, Cheap Hero or foreign leader revival is printed here.
-## Verification required for the next package
+## Verification and remaining boundaries
-Start with genuine unallied Fremen Cunning and natural empty appearances in both modes: compare initially empty against enemies devoured to empty, BG advisors, hidden zero/three/five markers, and multi-sector source groups. Verify rock-spice triggering territories, storm-blocked sources/destinations, occupied strongholds, typed movement and no normal movement charge. Extend to both Advanced piles, additional placement, summoned-parent recovery, canceled appearances, Sandtrout suppression, post-Nexus timing and nested arrival effects. Compare every seat's view and prove lost acknowledgments and concurrent requests cannot duplicate a ride. Secret Ally protection and revival require the specific policy/accounting boundaries above before broader integration is certified.
+Focused pure and engine checks exercise Basic and Advanced real second-turn empty natural appearances, accepted Advanced additional placement and an actual special-Karama summon, a public offer with and without the private card, card spend, Karama allowance/cancellation, post-Nexus ride, typed source custody, owner-only card view, rejected actions, legal bot movement and JSON normalization before/after the response. Pure quotes cover originally occupied appearances that later empty, advisors/No-Fields as presence, multi-sector starred sources, storm and occupied destinations, and inherited-object-key rejection. Isolated in-memory SQLite room tests race duplicate accepted natural and summoned offers, restore private seats and parent Spice Blow context through fresh room instances and prove a canceled or completed ride settles once. Local browser fixtures rendered both natural and summoned owner/non-holder offers, Karama waiting and remote source/force/destination controls from genuine engine views; intercepted GETs and blocked POSTs did not mutate a room.
+
+Still verify both Advanced pile identities end to end, Sandtrout suppression, nested arrival effects, wider reconnect scenarios and complete games. Great Maker, nested Cunning summons and combined optional modules are intentionally unavailable for this Cunning prototype. Fremen Secret Ally protection and the broader revival accounting above remain separate. These checks do not certify a complete Nexus module.
diff --git a/docs/VERIFICATION_WORKFLOW.md b/docs/VERIFICATION_WORKFLOW.md
index 897096a4..f20cba4e 100644
--- a/docs/VERIFICATION_WORKFLOW.md
+++ b/docs/VERIFICATION_WORKFLOW.md
@@ -29,6 +29,13 @@ occurred, what revision actually ran, the routes/actions exercised, results and
remaining limitations. If the deployed revision is older, label the results for
that older revision; do not claim that new source passed deployed acceptance.
If access or deployment is unavailable, report the gap and keep acceptance open.
+Each pushed application checkpoint also needs a separate live deployment; a
+successful GitHub container publication alone is not a deployment. Follow
+[the manual TrueNAS update](TRUENAS.md#applying-an-update) only after the image
+passes CI, the persistent volume is backed up and play reaches a safe point.
+If access, the backup or a safe point is missing, report the push as **not
+deployed** and keep its production verification open rather than claiming the
+Git revision label matches the pushed commit.
Use the production browser flow to verify relevant navigation and deep links,
bundled assets and rendering, HTTPS/proxy behavior, cookies and authentication,
diff --git a/game/admin-seat-ai-shape.ts b/game/admin-seat-ai-shape.ts
index ee379df3..5fa05240 100644
--- a/game/admin-seat-ai-shape.ts
+++ b/game/admin-seat-ai-shape.ts
@@ -95,6 +95,8 @@ const decisions = {
greatMakerVote: true,
greatMakerRide: true,
wormRide: true,
+ nexusFremenCunningOffer: true,
+ nexusFremenCunningRide: true,
moritaniRetention: true,
battleCards: true,
} satisfies Record;
@@ -134,6 +136,8 @@ const responses = {
nexusAdvisorFlip: true,
nexusSardaukar: true,
nexusGuildCunning: true,
+ nexusFremenCunning: true,
+ nexusEcazBetrayal: true,
bgCharity: true,
choamCharity: true,
choamInflation: true,
diff --git a/game/bots.ts b/game/bots.ts
index 3193463d..613936e2 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -14,6 +14,8 @@ import { nexusChoamTradeBotActions } from './nexus-choam-trade-options';
import { nexusGuildCunningAction, nexusGuildCunningActive, nexusGuildHajrAction, nexusGuildMovementAvailable, nexusGuildShipmentAvailable, nexusGuildSkipShipmentAction } from './nexus-guild-cunning-options';
import { nexusMoritaniBotActions } from './nexus-moritani-options';
import { moritaniBetrayalBotActions } from './nexus-moritani-betrayal-options';
+import { ecazBetrayalBotActions } from './nexus-ecaz-betrayal-options';
+import { ecazBetrayalOffer } from './nexus-ecaz-betrayal';
import { ECAZ_START_FORCES, ECAZ_START_LOCATIONS, quoteEcazStartingForces } from './ecaz-setup';
import { choamPowerAction, choamPowerBotPlay } from './choam-power-options';
import { nexusCardBotActions } from './nexus-card-options';
@@ -1445,13 +1447,19 @@ function policyActions(g: GameView): Action[] {
fighterCount(me, splitLocation(g.response.location).territory) > 0) ||
(g.response.kind === 'nexusAdvisorFlip' &&
!!g.nexusAdvisors?.pending?.territories.some((territory) => fighterCount(me, territory) > 0));
+ const ecazBetrayalThreat =
+ g.response.kind === 'nexusEcazBetrayal' &&
+ !!g.response.location &&
+ fighterCount(me, g.response.location) > 0 &&
+ !!me.ally &&
+ g.players.some(player => player.id === me.ally && player.faction === 'ecaz');
const card = me.hand?.find((c) =>
g.responseControls?.cancelCards.includes(c.id),
);
if (
card &&
benefitEnemy &&
- ((level >= 2 && (targeted || movementThreat || auditThreat)) ||
+ ((level >= 2 && (targeted || movementThreat || ecazBetrayalThreat || auditThreat)) ||
(level === 3 &&
([
'harkonnenBonus',
@@ -2500,6 +2508,31 @@ function policyActions(g: GameView): Action[] {
if (d.kind === 'discoveryEntry') return discoveryEntryBotActions(g);
if (d.kind === 'ecologicalStorm') return discoveryStormActions(g);
if (d.kind === 'greatMakerVote' || d.kind === 'greatMakerRide') return greatMakerBotActions(g);
+ if (d.kind === 'nexusFremenCunningOffer')
+ return [{ type: 'decision', event: d.event,
+ accept: g.nexusCards?.card === 'fremen' && me.faction === 'fremen' &&
+ !me.ally && Object.entries(me.forces).some(([key, count]) =>
+ count > 0 && splitLocation(key).sector !== g.storm &&
+ TERRITORIES.some(t => t.id === splitLocation(key).territory && t.type === 'sand')) }];
+ if (d.kind === 'nexusFremenCunningRide') {
+ const decline: Action = { type: 'decision', event: d.event, accept: false };
+ const candidates: Action[] = [];
+ for (const [key, count] of Object.entries(me.forces)) {
+ if (count <= 0 || splitLocation(key).sector === g.storm) continue;
+ const source = splitLocation(key).territory;
+ if (!TERRITORIES.some(t => t.id === source && t.type === 'sand')) continue;
+ const elite = me.elites?.forces[key] ?? 0;
+ const forces = { [key]: { normal: count - elite, elite } };
+ for (const to of destinations(g)) {
+ if (to.t === source || !botEntryAllowed(g, me, to.t, to.s, 'move') ||
+ territoryEntryBlock(g.players, me.id, to.t)) continue;
+ candidates.push({ type: 'decision', event: d.event, accept: true,
+ source, forces, territory: to.t, sector: to.s });
+ if (candidates.length >= 16) return [...candidates, decline];
+ }
+ }
+ return [...candidates, decline];
+ }
if (d.kind === 'wormProtection')
return [{ type: 'decision', accept: true }];
if (d.kind === 'stormLosses')
@@ -3798,6 +3831,8 @@ export function botActions(g: GameView): Action[] {
if (trade.length) return trade;
const moritaniBetrayal = moritaniBetrayalBotActions(g);
if (moritaniBetrayal.length) return moritaniBetrayal;
+ const ecazBetrayal = ecazBetrayalBotActions(g);
+ if (ecazBetrayal.length) return ecazBetrayal;
const sardaukar = nexusSardaukarBotActions(g);
if (sardaukar.length) return sardaukar;
const advisorConversion = nexusAdvisorBotActions(g);
@@ -3919,9 +3954,15 @@ export function runBots(state: Game, limit = 96): Game {
for (let step = 0; step < limit; step++) {
let next: Game | undefined;
const actors = g.players.filter((p) => p.bot ?? p.autopilot);
- // Give an automated optional supplier a chance before a seated-earlier
- // recipient spends the shipment. Humans retain their ordinary actions.
- if (g.phase === 5 && junctionSponsor(g) && !currentJunctionOffer(g))
+ // A card holder's only pre-shipment declaration must run before an
+ // earlier-seated movement bot consumes the shared opening.
+ const ecazHolder = g.phase === 5 && g.nexusCards?.cards
+ ? actors.find(actor => g.nexusCards!.cards!.hands[actor.id] === 'ecaz' &&
+ ecazBetrayalOffer(g, actor.id)?.blocked === null)
+ : undefined;
+ if (ecazHolder)
+ actors.sort((a, b) => Number(b.id === ecazHolder.id) - Number(a.id === ecazHolder.id));
+ else if (g.phase === 5 && junctionSponsor(g) && !currentJunctionOffer(g))
actors.sort((a, b) => Number(b.faction === 'guild') - Number(a.faction === 'guild'));
for (const p of actors) {
for (const action of botActions(viewGame(g, p.id))) {
diff --git a/game/engine.ts b/game/engine.ts
index d058800c..17b64e81 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -34,6 +34,7 @@ import { greatMakerSignature, greatMakerMajority, validateGreatMaker, type Great
import { quoteNexusChoamTrade, validateNexusChoamTrade, nexusChoamTradeSignature, type NexusChoamTrade } from './nexus-choam-trade';
import { EMPEROR_NEXUS_REVIVALS, emperorNexusEvent, emperorNexusSignature, emperorNexusModeSupported, emperorNexusPools, emperorNexusRevivalElites, validateEmperorNexusRevival, type NexusEmperorRevival } from './nexus-emperor-secret-ally';
import { FREMEN_NEXUS_FREE_FORCES, fremenNexusRevivalOffer, fremenNexusRevivalPools, fremenNexusRevivalSignature, validateFremenNexusRevival, type FremenNexusRevival } from './nexus-fremen-revival';
+import { captureFremenCunningOccurrence, quoteFremenCunning, quoteFremenCunningRide, validateFremenCunningSelection, type FremenCunningOccurrence, type FremenCunningRideAuthorization, type FremenCunningSelection } from './nexus-fremen-cunning';
import { truthKnowledgeOf } from './truthtrance-knowledge';
import { createMoritaniAssassinateOpportunity, moritaniAssassinateTrigger, moritaniAssassinateChoices, quoteMoritaniAssassinate, moritaniAssassinateSignature, validateMoritaniAssassinate, type MoritaniAssassinateState, type MoritaniAssassinateReceipt } from './moritani-assassinate';
import { createNexusGuildSecretAlly, validateNexusGuildSecretAlly, quoteNexusGuildSecretShipment, type NexusGuildSecretAllyReceipt } from './nexus-guild-secret-ally';
@@ -48,6 +49,7 @@ import { quoteNexusAdvisors, createNexusAdvisors, validateNexusAdvisors, type Ne
import { createNexusSardaukar, validateNexusSardaukar, type NexusSardaukarReceipt } from './nexus-sardaukar';
import { createNexusMoritani, validateNexusMoritani, quoteNexusMoritaniPlacement, type NexusMoritaniReceipt } from './nexus-moritani';
import { moritaniBetrayalOffer } from './nexus-moritani-betrayal';
+import { ecazBetrayalOffer, quoteEcazBetrayal, validateEcazBetrayalSnapshot, type EcazBetrayalSnapshot } from './nexus-ecaz-betrayal';
import { nexusMoritaniRecordSignature, terrorLocationAllowed, terrorEntryLocationAllowed } from './terror-location';
import { CHOAM_NEXUS_EFFECTS, createNexusChoam, validateNexusChoam, type NexusChoamEffect, type NexusChoamReceipt } from './nexus-choam';
import { createTraitorDeclaration, validateTraitorDeclarations, type TraitorDeclaration, type TraitorDeclarationContext } from './traitor-declarations';
@@ -863,6 +865,8 @@ export type Decision =
| { kind: 'greatMakerVote'; player: string; event: string }
| { kind: 'greatMakerRide'; player: string; event: string }
| { kind: 'wormRide'; player: string; territory: string }
+ | { kind: 'nexusFremenCunningOffer'; player: string; event: string }
+ | { kind: 'nexusFremenCunningRide'; player: string; event: string }
| {
kind: 'moritaniRetention';
owner: string;
@@ -931,6 +935,8 @@ export type ResponseWindow = {
| 'advisorFlip'
| 'nexusAdvisorFlip'
| 'nexusSardaukar'
+ | 'nexusFremenCunning'
+ | 'nexusEcazBetrayal'
| 'nexusGuildCunning'
| 'bgCharity'
| 'choamCharity'
@@ -1450,6 +1456,16 @@ export type Game = {
/** Optional independent module; complete effect coverage is still release-gated. */
nexusCards?: { cards: NexusState | null; phase: NexusCardPhase | null } | null;
nexusTraitorExchanges?: NexusTraitorExchange[];
+ /** An initially empty actual worm occurrence; the optional card is not disclosed by this offer. */
+ nexusFremenCunningOffer?: { occurrence: FremenCunningOccurrence; owner: string } | null;
+ /** Accepted appearances wait for post-Nexus riding, then retain completion custody. */
+ nexusFremenCunningRides?: {
+ occurrence: FremenCunningOccurrence;
+ authorization: FremenCunningRideAuthorization;
+ stage: 'pending' | 'queued' | 'select' | 'complete';
+ }[];
+ /** A spent Ecaz card binds one exact allied force group until Karama settles. */
+ nexusEcazBetrayalHistory?: { snapshot: EcazBetrayalSnapshot; stage: 'pending' | 'complete' }[];
nexusFaceDancerHistory?: NexusFaceDancerReceipt[];
nexusSuboidHistory?: NexusSuboidReceipt[];
nexusSuboidLast?: { event: string; owner: string; turn: number };
@@ -1480,6 +1496,8 @@ export type Game = {
summonedNexusBeforeRides?: boolean;
summonedWorm?: {
territory: string;
+ /** Server-created identity for this one physical special-Karama appearance. */
+ event: string;
resume: Pick<
Game,
| 'response'
@@ -1791,6 +1809,119 @@ function playNexusEmperorRevive(g:Game,p:Player,action:Action) {
log(g,`${p.name} spent Emperor Nexus Secret Ally to return three forces${elite ? `, including ${elite} Fedaykin` : ''} from Tanks to reserves for free. Ordinary force and free-revival allowances are unchanged.`,
{faction:p.faction,name:'Emperor Nexus revival'});
}
+function nexusEcazBetrayalIntegrity(g: Game) {
+ const history = g.nexusEcazBetrayalHistory;
+ if (history === undefined) return;
+ requireRule(Array.isArray(history) && history.length > 0 && g.nexusCards?.cards,
+ 'Ecaz Betrayal has lost its spent card history.');
+ const events = new Set();
+ let pending = 0;
+ for (const record of history) {
+ const snapshot = record?.snapshot;
+ requireRule(snapshot && typeof snapshot.event === 'string' &&
+ !events.has(snapshot.event) &&
+ Number.isSafeInteger(snapshot.turn) && snapshot.turn > 0 && snapshot.turn <= g.turn &&
+ Number.isSafeInteger(snapshot.discardIndex) && snapshot.discardIndex >= 0 &&
+ g.players.some(player => player.id === snapshot.owner && player.faction !== 'ecaz') &&
+ g.players.some(player => player.id === snapshot.ally) &&
+ ['pending', 'complete'].includes(record.stage),
+ 'Ecaz Betrayal has lost its unique owner, ally or turn.');
+ events.add(snapshot.event);
+ if (record.stage === 'pending') {
+ pending++;
+ requireRule(pending === 1 && record === history.at(-1) &&
+ ((g.response?.kind === 'nexusEcazBetrayal' &&
+ g.response.owner === snapshot.owner &&
+ g.response.intent === snapshot.event &&
+ g.response.location === snapshot.territory) ||
+ (g.pendingKarama?.use.kind === 'cancel' &&
+ g.pendingKarama.use.response.kind === 'nexusEcazBetrayal' &&
+ g.pendingKarama.use.response.intent === snapshot.event)),
+ 'Ecaz Betrayal has lost its pending Karama response.');
+ nexusRule(() => validateEcazBetrayalSnapshot(g, snapshot, true));
+ } else if (snapshot.turn === g.turn) {
+ requireRule(g.nexusCards!.cards!.discard[snapshot.discardIndex] === 'ecaz',
+ 'Ecaz Betrayal has reopened its spent card.');
+ }
+ }
+}
+
+function nexusFremenCunningIntegrity(g: Game) {
+ const offer = g.nexusFremenCunningOffer;
+ const rides = g.nexusFremenCunningRides;
+ if (offer === undefined && rides === undefined) return;
+ requireRule(g.nexusCards?.cards && (!rides || Array.isArray(rides)),
+ 'Fremen Cunning has lost its physical Nexus module or saved rides.');
+ const suspended = g.summonedWorm?.resume;
+ if (offer) {
+ const occurrence = offer.occurrence;
+ const decision = g.decision?.kind === 'nexusFremenCunningOffer'
+ ? g.decision : suspended?.decision;
+ requireRule(g.status === 'playing' && g.phase === 1 &&
+ occurrence.turn === g.turn && occurrence.initiallyEmpty &&
+ occurrence.event && byFaction(g, 'fremen')?.id === offer.owner &&
+ decision?.kind === 'nexusFremenCunningOffer' &&
+ decision.player === offer.owner && decision.event === occurrence.event,
+ 'The empty worm appearance has lost its original public offer.');
+ if (occurrence.origin === 'summoned') requireRule(
+ occurrence.parent && g.summonedWorm?.event === occurrence.parent &&
+ g.summonedWorm?.territory === occurrence.territory,
+ 'The empty summoned worm has lost its original special Karama parent.',
+ );
+ }
+ const events = new Set();
+ for (const ride of rides ?? []) {
+ const { occurrence, authorization, stage } = ride;
+ requireRule(occurrence.initiallyEmpty && occurrence.event &&
+ !events.has(occurrence.event) && authorization.event === occurrence.event &&
+ authorization.turn === occurrence.turn && occurrence.turn <= g.turn &&
+ authorization.owner && getPlayer(g, authorization.owner).faction === 'fremen' &&
+ ['pending', 'queued', 'select', 'complete'].includes(stage),
+ 'Fremen Cunning has lost its unique accepted worm appearance.');
+ requireRule(
+ ['natural', 'additional', 'summoned'].includes(occurrence.origin) &&
+ (occurrence.origin !== 'summoned' ||
+ (typeof occurrence.parent === 'string' && occurrence.parent.length > 0 &&
+ (stage !== 'pending' || (g.summonedWorm?.event === occurrence.parent &&
+ g.summonedWorm?.territory === occurrence.territory)))),
+ 'Fremen Cunning has lost its summoned worm parent.',
+ );
+ events.add(occurrence.event);
+ if (stage === 'complete') continue;
+ requireRule(g.status === 'playing' && g.phase === 1 &&
+ occurrence.turn === g.turn && g.nexusCards!.cards!.discard.includes('fremen'),
+ 'A pending Fremen ride has lost its spent physical card.');
+ if (stage === 'select') {
+ const decision = g.decision?.kind === 'nexusFremenCunningRide'
+ ? g.decision : suspended?.decision;
+ requireRule(decision?.kind === 'nexusFremenCunningRide' &&
+ decision.player === authorization.owner &&
+ decision.event === occurrence.event,
+ 'Fremen Cunning has lost its current rider choice.');
+ }
+ if (stage === 'pending') {
+ const live = g.response;
+ const prior = suspended?.response;
+ const liveKarama = g.pendingKarama;
+ const priorKarama = suspended?.pendingKarama;
+ requireRule(
+ (live?.kind === 'nexusFremenCunning' &&
+ live.owner === authorization.owner && live.intent === occurrence.event) ||
+ (prior?.kind === 'nexusFremenCunning' &&
+ prior.owner === authorization.owner && prior.intent === occurrence.event) ||
+ (liveKarama?.use.kind === 'cancel' &&
+ liveKarama.use.response.kind === 'nexusFremenCunning' &&
+ liveKarama.use.response.intent === occurrence.event) ||
+ (priorKarama?.use.kind === 'cancel' &&
+ priorKarama.use.response.kind === 'nexusFremenCunning' &&
+ priorKarama.use.response.intent === occurrence.event),
+ 'Fremen Cunning has lost its pending cancellation window.');
+ }
+ }
+ requireRule(!offer || !events.has(offer.occurrence.event),
+ 'A worm occurrence cannot be both offered and already spent.');
+}
+
function nexusFremenRevivalIntegrity(g: Game) {
const history = g.nexusFremenRevivalHistory, events = g.nexusFremenRevivalEvents;
if (history === undefined) {
@@ -1869,6 +2000,52 @@ function playMoritaniBetrayal(g: Game, p: Player, action: Action) {
log(g, `${p.name} spent Moritani Nexus Betrayal to return a hidden Terror token from ${territory(target.territory).name} to Moritani's supply without revealing its face.`,
{ faction: p.faction, name: 'Moritani Nexus Betrayal' });
}
+function currentEcazBetrayalResponse(g: Game, response: ResponseWindow) {
+ const record = g.nexusEcazBetrayalHistory?.find(row =>
+ row.stage === 'pending' && row.snapshot.event === response.intent);
+ requireRule(response.kind === 'nexusEcazBetrayal' && record &&
+ response.owner === record.snapshot.owner &&
+ response.location === record.snapshot.territory,
+ 'The Ecaz Betrayal interruption has lost its spent card or territory.');
+ nexusRule(() => validateEcazBetrayalSnapshot(g, record.snapshot, true));
+ return record;
+}
+function finishEcazBetrayalResponse(g: Game, response: ResponseWindow, canceled: boolean) {
+ const record = currentEcazBetrayalResponse(g, response);
+ const ally = getPlayer(g, record.snapshot.ally);
+ if (!canceled) validateReserveReturnCounters(ally, record.snapshot.total, 'Ecaz Betrayal');
+ record.stage = 'complete';
+ if (!canceled) {
+ for (const key of Object.keys(record.snapshot.sectors)) {
+ delete ally.forces[key];
+ if (ally.elites) delete ally.elites.forces[key];
+ }
+ ally.reserves += record.snapshot.total.normal + record.snapshot.total.elite;
+ if (ally.elites) ally.elites.reserves += record.snapshot.total.elite;
+ observeOccupation(g);
+ }
+ log(g, canceled
+ ? `Karama prevented the Ecaz Nexus return from ${territory(record.snapshot.territory).name}; the Nexus card remains spent.`
+ : `${ally.name} returned ${record.snapshot.total.normal + record.snapshot.total.elite} forces from ${territory(record.snapshot.territory).name} to reserves under Ecaz Nexus Betrayal.`,
+ { faction: ally.faction, name: 'Ecaz Nexus Betrayal' });
+}
+function playEcazBetrayal(g: Game, p: Player, action: Action) {
+ const offer = ecazBetrayalOffer(g, p.id,
+ g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g));
+ requireRule(offer && !offer.blocked &&
+ Object.keys(action).sort().join(',') === 'event,territory,type' &&
+ typeof action.event === 'string' && typeof action.territory === 'string',
+ offer?.blocked ?? 'Choose one currently shared Ecaz territory.');
+ const snapshot = nexusRule(() =>
+ quoteEcazBetrayal(g, p.id, stringField(action.event), stringField(action.territory)));
+ g.nexusCards!.cards = nexusRule(() =>
+ discardNexusCard(g.nexusCards!.cards!, p.id, g.players));
+ (g.nexusEcazBetrayalHistory ??= []).push({ snapshot, stage: 'pending' });
+ g.response = { kind: 'nexusEcazBetrayal', owner: p.id, intent: snapshot.event,
+ location: snapshot.territory, passed: [] };
+ log(g, `${p.name} spent Ecaz Nexus Betrayal to require Ecaz’s ally to return all forces from ${territory(snapshot.territory).name}, unless Karama prevents the return.`,
+ { faction: p.faction, name: 'Ecaz Nexus Betrayal' });
+}
function markNexusOccurred(g: Game) {
if (!g.nexusCards) return;
const phase = g.nexusCards.phase;
@@ -6391,8 +6568,40 @@ function devour(
if (fremen && at(fremen, t)) g.wormRides.push(t);
log(g, `Shai-Hulud appeared in ${territory(t).name}.`);
}
-function beginWorm(g: Game, t: string) {
+function beginWorm(g: Game, t: string, origin: 'natural' | 'additional' | 'summoned' = 'natural') {
const fremen = byFaction(g, 'fremen');
+ // An empty appearance offers the same decision with or without the private
+ // Fremen card; the offer itself must not disclose its custody.
+ if ((origin !== 'summoned' || (g.summonedWorm?.event &&
+ !g.nexusFremenCunningOffer &&
+ !g.nexusFremenCunningRides?.some(row => row.stage === 'pending' || row.stage === 'select'))) &&
+ !g.greatMaker && g.nexusCards?.cards &&
+ !g.expansions.length && !g.homeworlds && !g.leaderSkills &&
+ !g.discoveryEnabled && !g.discoveries && !g.discoveryStash &&
+ !g.techTokens && !g.strongholdCards &&
+ g.players.every(seat => ['atreides', 'harkonnen', 'emperor', 'guild', 'beneGesserit', 'fremen'].includes(seat.faction)) &&
+ fremen && !fremen.ally && g.phase === 1 &&
+ Object.entries(fremen.forces).some(([key, amount]) => {
+ if (amount <= 0) return false;
+ const source = splitLocation(key);
+ return source.sector !== g.storm &&
+ TERRITORIES.some(row => row.id === source.territory && row.type === 'sand');
+ })) {
+ const occurrence = captureFremenCunningOccurrence(g, {
+ event: crypto.randomUUID(), territory: t, origin,
+ ...(origin === 'summoned'
+ ? { parent: g.summonedWorm!.event }
+ : { pile: g.spiceSequence?.pile }),
+ });
+ if (occurrence.initiallyEmpty) {
+ requireRule(!g.nexusFremenCunningOffer,
+ 'Finish the previous worm appearance before another Cunning offer.');
+ g.nexusFremenCunningOffer = { occurrence, owner: fremen.id };
+ g.decision = { kind: 'nexusFremenCunningOffer', player: fremen.id,
+ event: occurrence.event };
+ return;
+ }
+ }
if (fremen?.ally && at(getPlayer(g, fremen.ally), t)) {
g.decision = {
kind: 'wormProtection',
@@ -6402,6 +6611,27 @@ function beginWorm(g: Game, t: string) {
};
} else wormSurvival(g, t);
}
+function currentFremenCunningResponse(g: Game, response: ResponseWindow) {
+ const ride = g.nexusFremenCunningRides?.find(row =>
+ row.stage === 'pending' && row.occurrence.event === response.intent);
+ requireRule(response.kind === 'nexusFremenCunning' &&
+ ride && response.owner === ride.authorization.owner &&
+ response.location === ride.occurrence.territory &&
+ ride.occurrence.turn === g.turn && g.phase === 1 &&
+ g.nexusCards?.cards?.discard.includes('fremen'),
+ 'The Fremen Nexus interruption has lost its spent card or worm appearance.');
+ return ride;
+}
+function finishFremenCunningResponse(g: Game, response: ResponseWindow, canceled: boolean) {
+ const ride = currentFremenCunningResponse(g, response);
+ ride.stage = canceled ? 'complete' : 'queued';
+ log(g, canceled
+ ? 'Karama prevented the Fremen Nexus remote ride; the original worm still resolves.'
+ : 'Fremen Nexus Cunning will ride from one desert territory after this Nexus.',
+ { faction: 'fremen', name: 'Fremen Nexus Cunning' });
+ wormSurvival(g, ride.occurrence.territory);
+}
+
function wormSurvival(g: Game, t: string, protectedAlly?: string) {
const fremen = byFaction(g, 'fremen');
if (fremen && at(fremen, t)) {
@@ -6453,6 +6683,13 @@ function afterWorm(g: Game) {
if (g.decision?.kind === 'wormRide') {
g.wormRides.unshift(g.decision.territory);
g.decision = null;
+ } else if (g.decision?.kind === 'nexusFremenCunningRide') {
+ const event = g.decision.event;
+ const ride = g.nexusFremenCunningRides?.find(row =>
+ row.stage === 'select' && row.occurrence.event === event);
+ requireRule(ride, 'The summoned worm has lost its interrupted Fremen Cunning ride.');
+ ride.stage = 'queued';
+ g.decision = null;
} else if (
g.pendingAmbassador?.resume === 'wormRide' ||
g.pendingTerrorEntry?.resume === 'wormRide' ||
@@ -6950,6 +7187,21 @@ function nextWormRide(g: Game) {
return;
}
}
+ for (const ride of g.nexusFremenCunningRides ?? []) {
+ if (ride.stage !== 'queued' || ride.occurrence.turn !== g.turn) continue;
+ const source = nexusRule(() => quoteFremenCunningRide(
+ g, ride.authorization.owner, ride.occurrence, ride.authorization,
+ ));
+ if (!source) {
+ ride.stage = 'complete';
+ log(g, 'Fremen Nexus Cunning has no remaining legal desert force source.');
+ continue;
+ }
+ ride.stage = 'select';
+ g.decision = { kind: 'nexusFremenCunningRide',
+ player: ride.authorization.owner, event: ride.occurrence.event };
+ return;
+ }
g.nexus = false;
finishSpicePass(g);
}
@@ -8603,6 +8855,10 @@ function validateKaramaUse(
if (use.response.kind === 'nexusPrescience') validateNexusInspectionResponse(g, use.response);
if (use.response.kind === 'nexusAdvisorFlip') validateNexusAdvisorResponse(g, use.response);
if (use.response.kind === 'nexusSardaukar') validateNexusSardaukarResponse(g, use.response);
+ if (use.response.kind === 'nexusFremenCunning')
+ currentFremenCunningResponse(g, use.response);
+ if (use.response.kind === 'nexusEcazBetrayal')
+ currentEcazBetrayalResponse(g, use.response);
if (isCombatResponseKind(use.response.kind))
currentCombatResponseQuote(g, {
kind: 'response',
@@ -12555,7 +12811,7 @@ function saphoOptions(g: Game, p: Player): SaphoOption[] {
!lot.acted.includes(p.id)
) {
const remaining = lot.order.filter((id) => !lot.acted.includes(id));
- if (!lot.acted.length && remaining[0] !== p.id)
+ if (remaining[0] !== p.id)
options.push({ scope: 'onceAround', event: lot.event, mode: 'first' });
if (remaining.at(-1) !== p.id)
options.push({ scope: 'onceAround', event: lot.event, mode: 'last' });
@@ -13869,17 +14125,18 @@ function currentHarassWithdrawQuote(g: Game, p: Player, plan: Pick= 0 &&
Number.isSafeInteger(p.reserves + returned.normal + returned.elite) &&
(!p.elites || (Number.isSafeInteger(p.elites.reserves) && p.elites.reserves >= 0 &&
p.elites.reserves <= p.reserves && Number.isSafeInteger(p.elites.reserves + returned.elite))),
- 'Harass & Withdraw needs valid reserve and elite counters before any forces return.');
+ `${source} needs valid reserve and elite counters before any forces return.`);
}
function harassWithdrawPreview(g: Game, p: Player): HarassWithdrawPreview | null {
const b = g.battle;
@@ -14562,6 +14819,8 @@ function marketGholaIntegrity(g: Game) {
nexusCardsIntegrity(g);
nexusEmperorSecretIntegrity(g);
nexusFremenRevivalIntegrity(g);
+ nexusFremenCunningIntegrity(g);
+ nexusEcazBetrayalIntegrity(g);
nexusTraitorIntegrity(g);
nexusFaceDancerIntegrity(g);
nexusSuboidIntegrity(g);
@@ -15830,7 +16089,7 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
pendingRetentionPresent: !!g.moritaniRetention,
});
for (const withdrawal of quote.harassWithdraw ?? [])
- validateHarassReturnCounters(getPlayer(g, withdrawal.player), withdrawal.returned);
+ validateReserveReturnCounters(getPlayer(g, withdrawal.player), withdrawal.returned);
if (quote.sukGraduate) requireRule(
!g.advanced || getPlayer(g, quote.winner!).faction !== 'atreides',
'Suk Graduate rescue for Advanced Atreides awaits the Kwisatz Haderach loss-count ruling.');
@@ -17654,6 +17913,8 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
if (response.kind === 'nexusPrescience') validateNexusInspectionResponse(g, response);
if (response.kind === 'nexusAdvisorFlip') validateNexusAdvisorResponse(g, response);
if (response.kind === 'nexusSardaukar') validateNexusSardaukarResponse(g, response);
+ if (response.kind === 'nexusFremenCunning') currentFremenCunningResponse(g, response);
+ if (response.kind === 'nexusEcazBetrayal') currentEcazBetrayalResponse(g, response);
if (response.kind === 'nexusGuildCunning') validateGuildCunningResponse(g,response);
if (response.kind === 'moritaniPlacement') {
// Both outcomes need the same physical inventory and declared source.
@@ -17695,6 +17956,14 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
: null;
if (!canceled && bureaucratDiversion === undefined && offerBureaucratPayment(g,response.bureaucratPayment,{kind:'response'})) return;
g.response = null;
+ if (response.kind === 'nexusEcazBetrayal') {
+ finishEcazBetrayalResponse(g, response, canceled);
+ return;
+ }
+ if (response.kind === 'nexusFremenCunning') {
+ finishFremenCunningResponse(g, response, canceled);
+ return;
+ }
if (response.kind === 'nexusGuildCunning') { settleGuildCunning(g,response,canceled); return; }
if (response.kind === 'nexusAdvisorFlip') {
finishNexusAdvisors(g, canceled);
@@ -18504,7 +18773,7 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
if (canceled) {
g.wormPlacementCanceledTurn = g.turn;
continueSpice(g);
- } else beginWorm(g, response.location!);
+ } else beginWorm(g, response.location!, 'additional');
} else if (response.kind === 'wormAllyProtection') {
wormSurvival(
g,
@@ -20580,6 +20849,7 @@ export function executeSpecialKaramaIntent(
p.specialKaramaUsed = true;
g.summonedWorm = {
territory: t,
+ event: crypto.randomUUID(),
resume: {
response: g.response,
decision: g.decision,
@@ -20605,7 +20875,7 @@ export function executeSpecialKaramaIntent(
g,
`${p.name} used special Karama to summon Shai-Hulud in ${territory(t).name}.`,
);
- beginWorm(g, t);
+ beginWorm(g, t, 'summoned');
} else if (intent.kind === 'atreides') {
const b = g.battle!,
target = intent.target;
@@ -21209,6 +21479,7 @@ function applyActionInner(
if (t === 'nexusEmperorRevive') { playNexusEmperorRevive(g,p,action); return g; }
if (t === 'nexusFremenRevive') { playNexusFremenRevive(g,p,action); return g; }
if (t === 'nexusMoritaniBetrayal') { playMoritaniBetrayal(g, p, action); return g; }
+ if (t === 'nexusEcazBetrayal') { playEcazBetrayal(g, p, action); return g; }
if (t === 'nexusAtreides') { playNexusAtreides(g, p, action); return g; }
requireRule(
!(
@@ -22222,7 +22493,7 @@ function applyActionInner(
let quote;
try { quote = quoteHarassWithdraw(offer.context, offer.dial, offer.support, action.returns as HarassWithdrawSelection); }
catch (error) { if (error instanceof HarassWithdrawError) throw new RuleError(error.message); throw error; }
- validateHarassReturnCounters(p, quote.returned);
+ validateReserveReturnCounters(p, quote.returned);
receipt.selection = structuredClone(quote.locations); receipt.stage = 'selected';
receipt.signature = harassAllocationSignature(receipt);
log(g, `${p.name} selected ${quote.returned.normal} ordinary and ${quote.returned.elite} elite undialed forces for Harass & Withdraw: ${Object.entries(quote.locations).map(([key, group]) => `${key} (${group.normal} ordinary, ${group.elite} elite)`).join('; ')}. The return waits for traitor declarations; an opposing successful call cancels it.`);
@@ -22583,6 +22854,32 @@ function applyActionInner(
passed: [],
};
} else continueSpice(g);
+ } else if (decision.kind === 'nexusFremenCunningOffer') {
+ const pending = g.nexusFremenCunningOffer;
+ requireRule(pending && pending.owner === id &&
+ pending.occurrence.event === decision.event &&
+ action.event === decision.event &&
+ typeof action.accept === 'boolean' &&
+ Object.keys(action).every(key => ['type', 'event', 'accept'].includes(key)),
+ 'Choose whether to use the current empty-worm Nexus opportunity.');
+ g.nexusFremenCunningOffer = null;
+ if (action.accept) {
+ requireRule(nexusRule(() => quoteFremenCunning(g, id, pending.occurrence)),
+ 'Fremen Cunning needs your own unallied Nexus card and a desert force source.');
+ g.nexusCards!.cards = nexusRule(() =>
+ discardNexusCard(g.nexusCards!.cards!, id, g.players));
+ g.nexusFremenCunningRides ??= [];
+ g.nexusFremenCunningRides.push({
+ occurrence: pending.occurrence,
+ authorization: { event: decision.event, turn: g.turn, owner: id },
+ stage: 'pending',
+ });
+ g.response = { kind: 'nexusFremenCunning', owner: id,
+ intent: decision.event, location: pending.occurrence.territory,
+ passed: [] };
+ log(g, `${p.name} spent Fremen Nexus Cunning on an empty worm appearance; a Karama response precedes the remote ride.`,
+ { faction: p.faction, name: 'Fremen Nexus Cunning' });
+ } else wormSurvival(g, pending.occurrence.territory);
} else if (decision.kind === 'wormProtection') {
requireRule(
typeof action.accept === 'boolean',
@@ -22612,6 +22909,47 @@ function applyActionInner(
decideGreatMakerVote(g,p,action);
} else if (decision.kind === 'greatMakerRide') {
decideGreatMakerRide(g,p,action);
+ } else if (decision.kind === 'nexusFremenCunningRide') {
+ const ride = g.nexusFremenCunningRides?.find(row =>
+ row.stage === 'select' && row.occurrence.event === decision.event);
+ requireRule(ride && ride.authorization.owner === id &&
+ action.event === decision.event && typeof action.accept === 'boolean',
+ 'Choose the pending Fremen Cunning remote worm ride.');
+ if (action.accept) {
+ requireRule(Object.keys(action).every(key =>
+ ['type', 'event', 'accept', 'source', 'forces', 'territory', 'sector'].includes(key)),
+ 'Choose only this Cunning source, forces and destination.');
+ const selection = {
+ source: stringField(action.source),
+ forces: action.forces as FremenCunningSelection['forces'],
+ destination: {
+ territory: stringField(action.territory),
+ sector: integer(action.sector, 0, 18, 'Sector'),
+ },
+ };
+ const chosen = nexusRule(() => validateFremenCunningSelection(
+ g, id, ride.occurrence, selection, ride.authorization));
+ allowedEntry(g, p, selection.destination.territory, selection.destination.sector);
+ const entries = Object.entries(selection.forces);
+ removeGroup(p, entries.map(([key, count]) => [key, count.normal + count.elite] as [string, number]),
+ Object.fromEntries(entries.map(([key, count]) => [key, count.elite])));
+ place(p, selection.destination.territory, selection.destination.sector,
+ chosen.total, chosen.elite);
+ ride.stage = 'complete';
+ log(g, `${p.name} used Fremen Nexus Cunning to ride ${chosen.total} forces from ${territory(selection.source).name} to ${territory(selection.destination.territory).name}.`,
+ { faction: p.faction, name: 'Fremen Nexus Cunning' });
+ const intruded = intrusion(g, p, selection.destination.territory, { wormRide: true });
+ if (openTerritoryEntry(g, p, selection.destination.territory,
+ selection.destination.sector, chosen.total, chosen.elite,
+ 'wormRide', 'wormRide') || intruded) return g;
+ } else {
+ requireRule(Object.keys(action).every(key =>
+ ['type', 'event', 'accept'].includes(key)),
+ 'Declining Cunning does not move forces.');
+ ride.stage = 'complete';
+ log(g, `${p.name} declined the Fremen Nexus remote ride.`);
+ }
+ nextWormRide(g);
} else if (decision.kind === 'wormRide') {
requireRule(
typeof action.accept === 'boolean',
@@ -25059,6 +25397,8 @@ export function viewGame(state: Game, id: string) {
nexusMoritani: nexusMoritaniOffer(g,id),
nexusMoritaniBetrayal: moritaniBetrayalOffer(g,id,
g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)),
+ nexusEcazBetrayal: ecazBetrayalOffer(g, id,
+ g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)),
nexusRichese: nexusRicheseOffer(g,id),
nexusGuildSecretAlly: nexusGuildSecretAllyOffer(g,id),
nexusGuildCunning: projectedNexusGuildCunning(g,id),
diff --git a/game/karama-context.ts b/game/karama-context.ts
index 50c132f2..975eaee5 100644
--- a/game/karama-context.ts
+++ b/game/karama-context.ts
@@ -84,6 +84,16 @@ const cancellationSources = {
...movementSource(g),
conversion: g.nexusAdvisorHistory?.find(record => record.stage === 'pending') ?? null,
}),
+ nexusFremenCunning: (g, response) => ({
+ ...wormSource(g),
+ appearance: g.nexusFremenCunningRides?.find(ride =>
+ ride.stage === 'pending' && ride.occurrence.event === response.intent) ?? null,
+ }),
+ nexusEcazBetrayal: (g, response) => ({
+ ...movementSource(g),
+ return: g.nexusEcazBetrayalHistory?.find(record =>
+ record.stage === 'pending' && record.snapshot.event === response.intent) ?? null,
+ }),
nexusGuildCunning: (g) => ({cunning:g.nexusGuildCunningHistory?.at(-1),active:g.active,remaining:g.movementRemaining}),
nexusSardaukar: (g) => ({
battle: battleSource(g),
diff --git a/game/nexus-ecaz-betrayal-options.ts b/game/nexus-ecaz-betrayal-options.ts
new file mode 100644
index 00000000..cfd9b675
--- /dev/null
+++ b/game/nexus-ecaz-betrayal-options.ts
@@ -0,0 +1,17 @@
+import type { Action, GameView } from './engine';
+
+/** The private offer binds the exact territory and current card to this turn. */
+export function ecazBetrayalAction(game: GameView, territory: string): Action | null {
+ const offer = game.nexusEcazBetrayal;
+ const owner = game.players.find(player => player.id === game.me);
+ if (!offer || offer.blocked || !owner || owner.ally || owner.faction === 'ecaz' ||
+ game.nexusCards?.card !== 'ecaz' || game.automaticContinuationPending ||
+ !offer.territories.some(choice => choice.territory === territory)) return null;
+ return { type: 'nexusEcazBetrayal', event: offer.event, territory };
+}
+
+export function ecazBetrayalBotActions(game: GameView): Action[] {
+ const territory = game.nexusEcazBetrayal?.territories[0]?.territory;
+ const action = territory ? ecazBetrayalAction(game, territory) : null;
+ return action ? [action] : [];
+}
diff --git a/game/nexus-ecaz-betrayal.ts b/game/nexus-ecaz-betrayal.ts
new file mode 100644
index 00000000..854a1397
--- /dev/null
+++ b/game/nexus-ecaz-betrayal.ts
@@ -0,0 +1,195 @@
+import { TERRITORIES, splitLocation, validLocation } from './board';
+import type { Game, Player } from './engine';
+import { nexusCardMode, validateNexusCards } from './nexus-cards';
+
+export type EcazBetrayalCounts = Readonly<{ normal: number; elite: number }>;
+export type EcazBetrayalTerritory = Readonly<{
+ territory: string;
+ /** Complete physical ally group, keyed by printed board location. */
+ sectors: Readonly>;
+ total: EcazBetrayalCounts;
+}>;
+export type EcazBetrayalOffer = Readonly<{
+ event: string;
+ blocked: string | null;
+ ally: string | null;
+ territories: readonly EcazBetrayalTerritory[];
+}>;
+export type EcazBetrayalSnapshot = Readonly<{
+ event: string;
+ owner: string;
+ ally: string;
+ turn: number;
+ discardIndex: number;
+ territory: string;
+ sectors: Readonly>;
+ total: EcazBetrayalCounts;
+}>;
+
+const allowedFactions: Record = {
+ atreides: true, harkonnen: true, emperor: true, fremen: true,
+ guild: true, beneGesserit: true, ecaz: true, moritani: true,
+};
+const plain = (value: unknown): value is Record =>
+ !!value && typeof value === 'object' && !Array.isArray(value) &&
+ (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
+const count = (value: unknown): value is number =>
+ typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
+const exactKeys = (value: unknown, keys: readonly string[]): value is Record =>
+ plain(value) && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
+function openingReady(g: Game): boolean {
+ return g.status === 'playing' && g.phase === 5 &&
+ Array.isArray(g.order) && g.order.length === g.players.length &&
+ new Set(g.order).size === g.players.length &&
+ g.players.every(p => g.order.includes(p.id) && p.shipped === false && p.moved === 0) &&
+ g.active === g.order[0] && Array.isArray(g.movementRemaining) &&
+ g.movementRemaining.length === g.order.length &&
+ g.movementRemaining.every((id, index) => id === g.order[index]);
+}
+
+function locations(player: Player): Map> {
+ if (!plain(player.forces) || (player.elites && !plain(player.elites.forces)))
+ throw new Error('Ecaz Betrayal needs plain physical force custody.');
+ const elites = player.elites?.forces;
+ for (const [key, elite] of Object.entries(elites ?? {})) {
+ if (!count(elite) || (elite > 0 && !Object.hasOwn(player.forces, key)))
+ throw new Error('Ecaz Betrayal has invalid elite custody.');
+ }
+ const result = new Map>();
+ for (const [key, total] of Object.entries(player.forces)) {
+ const { territory, sector } = splitLocation(key);
+ if (!TERRITORIES.some(t => t.id === territory) ||
+ !Number.isSafeInteger(sector) || !validLocation(territory, sector) ||
+ key !== `${territory}:${sector}` || !count(total))
+ throw new Error('Ecaz Betrayal has invalid physical board custody.');
+ const elite = elites && Object.hasOwn(elites, key) ? elites[key] : 0;
+ if (!count(elite) || elite > total)
+ throw new Error('Ecaz Betrayal has invalid typed force custody.');
+ if (!total) continue;
+ const sectors = result.get(territory) ?? {};
+ sectors[key] = { normal: total - elite, elite };
+ result.set(territory, sectors);
+ }
+ return result;
+}
+
+function territoryQuote(territory: string, sectors: Record): EcazBetrayalTerritory {
+ let normal = 0, elite = 0;
+ for (const group of Object.values(sectors)) {
+ normal += group.normal;
+ elite += group.elite;
+ }
+ if (!Number.isSafeInteger(normal) || !Number.isSafeInteger(elite))
+ throw new Error('Ecaz Betrayal force totals exceed physical custody.');
+ return { territory, sectors, total: { normal, elite } };
+}
+
+/** Public-only offer for an unallied holder of Ecaz's actual Betrayal card.
+ * The engine separately authorizes card custody and handles Karama response. */
+export function ecazBetrayalOffer(g: Game, owner: string, automaticPending = false): EcazBetrayalOffer | null {
+ const holder = g.players.find(p => p.id === owner);
+ const ecaz = g.players.find(p => p.faction === 'ecaz');
+ const cards = g.nexusCards?.cards;
+ if (!holder || !ecaz || holder.id === ecaz.id || holder.faction === 'ecaz' ||
+ !cards || !Object.hasOwn(cards.hands, owner) || cards.hands[owner] !== 'ecaz' ||
+ nexusCardMode('ecaz', holder.faction, g.players.map(p => p.faction)) !== 'betrayal') return null;
+ const ally = g.players.find(p => p.id === ecaz.ally && p.ally === ecaz.id);
+ const event = JSON.stringify(['nexusEcazBetrayal', g.turn, owner, ally?.id ?? null, cards.discard.length]);
+ let blocked: string | null = null;
+ if (holder.ally) blocked = 'An allied player cannot hold or play a Nexus card.';
+ else if (!openingReady(g))
+ blocked = 'Use Ecaz Betrayal before the first Shipment and Movement action.';
+ else if (!ally) blocked = 'Ecaz needs a real reciprocal alliance.';
+ else if (g.expansions.some(expansion => expansion !== 'ecaz') ||
+ g.players.some(p => !Object.hasOwn(allowedFactions, p.faction)) ||
+ g.homeworlds || g.leaderSkills || g.discoveryEnabled || g.discoveries ||
+ g.discoveryStash || g.greatMaker || g.ecazTreachery || g.techTokens ||
+ g.strongholdCards || g.sandtrout || g.players.some(p => p.noField ||
+ (p.advisors && Object.keys(p.advisors).length > 0)))
+ blocked = 'Combined modules, No-Fields and advisor stances remain unavailable.';
+ else if (automaticPending || g.response || g.decision || g.truthtrance ||
+ g.phaseOpening || g.battle || g.pendingTreacheryDiscard || g.pendingExchange ||
+ g.pendingKarama || g.pendingRevival || g.pendingAmbassador || g.pendingCapture ||
+ g.pendingFremenMove || g.pendingMobileMove || g.pendingIxMove || g.pendingNullentropy ||
+ g.pendingTerrorEntry || g.pendingMoritaniPlacement || g.pendingRicheseGift ||
+ g.nexusTraitorPending || g.nexusCards?.phase?.stage === 'drawing')
+ blocked = 'Finish the current interaction before using Ecaz Betrayal.';
+ if (!blocked) validateNexusCards(cards, g.players);
+ const territories: EcazBetrayalTerritory[] = [];
+ if (!blocked && ally) {
+ const occupied = locations(ecaz);
+ for (const [territory, sectors] of locations(ally))
+ if (occupied.has(territory)) territories.push(territoryQuote(territory, sectors));
+ if (!territories.length) blocked = 'Ecaz and its ally share no territory containing physical forces.';
+ }
+ return { event, blocked, ally: ally?.id ?? null, territories };
+}
+
+/** Quote an accepted *entire* ally group before spending the card or returning
+ * forces. All inputs are the authoritative live Game and authenticated owner,
+ * plus the submitted event and one offered public territory. Throws if stale. */
+export function quoteEcazBetrayal(g: Game, owner: string, event: string, territory: string): EcazBetrayalSnapshot {
+ const offer = ecazBetrayalOffer(g, owner);
+ if (!offer || offer.blocked || event !== offer.event)
+ throw new Error(offer?.blocked ?? 'This Ecaz Betrayal opportunity is stale.');
+ const selected = offer.territories.find(row => row.territory === territory);
+ if (!selected) throw new Error('Choose one shared territory with Ecaz and its ally.');
+ return { event, owner, ally: offer.ally!, turn: g.turn, territory,
+ discardIndex: g.nexusCards!.cards!.discard.length,
+ sectors: selected.sectors, total: selected.total };
+}
+
+/** Validate a saved pre-return quote against frozen authoritative physical
+ * custody. pending=true is only for the engine's own saved, already-spent card
+ * response frame; the caller must authenticate that frame separately. */
+export function validateEcazBetrayalSnapshot(
+ g: Game, snapshot: EcazBetrayalSnapshot, pending = false,
+): void {
+ if (!exactKeys(snapshot, ['event', 'owner', 'ally', 'turn', 'territory', 'discardIndex', 'sectors', 'total']) ||
+ typeof snapshot.event !== 'string' || typeof snapshot.owner !== 'string' ||
+ typeof snapshot.ally !== 'string' || typeof snapshot.territory !== 'string' ||
+ !Number.isSafeInteger(snapshot.turn) || snapshot.turn !== g.turn ||
+ !Number.isSafeInteger(snapshot.discardIndex) || snapshot.discardIndex < 0 ||
+ !exactKeys(snapshot.total, ['normal', 'elite']) ||
+ !count(snapshot.total.normal) || !count(snapshot.total.elite) ||
+ !plain(snapshot.sectors) || !Object.keys(snapshot.sectors).length)
+ throw new Error('Ecaz Betrayal has an invalid saved force snapshot.');
+ for (const [key, group] of Object.entries(snapshot.sectors)) {
+ const { territory, sector } = splitLocation(key);
+ if (territory !== snapshot.territory || !TERRITORIES.some(t => t.id === territory) ||
+ !Number.isSafeInteger(sector) || !validLocation(territory, sector) ||
+ key !== `${territory}:${sector}` ||
+ !exactKeys(group, ['normal', 'elite']) || !count(group.normal) || !count(group.elite) ||
+ group.normal + group.elite <= 0 || !Number.isSafeInteger(group.normal + group.elite))
+ throw new Error('Ecaz Betrayal has an invalid saved sector group.');
+ }
+ let fresh: EcazBetrayalSnapshot;
+ if (pending) {
+ const cards = g.nexusCards?.cards;
+ const ecaz = g.players.find(p => p.faction === 'ecaz');
+ const ally = g.players.find(p => p.id === snapshot.ally);
+ const holder = g.players.find(p => p.id === snapshot.owner);
+ if (!openingReady(g) || !cards || cards.hands[snapshot.owner] !== null ||
+ cards.discard[snapshot.discardIndex] !== 'ecaz' || !ecaz || !ally ||
+ ecaz.ally !== ally.id || ally.ally !== ecaz.id || !holder ||
+ holder.ally || holder.faction === 'ecaz' ||
+ snapshot.event !== JSON.stringify(['nexusEcazBetrayal', g.turn,
+ snapshot.owner, ally.id, snapshot.discardIndex]))
+ throw new Error('Ecaz Betrayal has lost its pending card or alliance.');
+ validateNexusCards(cards, g.players);
+ const occupied = locations(ecaz);
+ const selected = locations(ally).get(snapshot.territory);
+ if (!occupied.has(snapshot.territory) || !selected)
+ throw new Error('Ecaz Betrayal has lost the shared physical territory.');
+ fresh = { event: snapshot.event, turn: g.turn, owner: snapshot.owner,
+ ally: ally.id, discardIndex: snapshot.discardIndex,
+ ...territoryQuote(snapshot.territory, selected) };
+ } else fresh = quoteEcazBetrayal(g, snapshot.owner, snapshot.event, snapshot.territory);
+ if (snapshot.ally !== fresh.ally || snapshot.discardIndex !== fresh.discardIndex ||
+ snapshot.total.normal !== fresh.total.normal || snapshot.total.elite !== fresh.total.elite ||
+ Object.keys(snapshot.sectors).length !== Object.keys(fresh.sectors).length ||
+ Object.entries(fresh.sectors).some(([key, group]) =>
+ !Object.hasOwn(snapshot.sectors, key) ||
+ snapshot.sectors[key].normal !== group.normal || snapshot.sectors[key].elite !== group.elite))
+ throw new Error('Ecaz Betrayal no longer matches its original physical ally group.');
+}
diff --git a/game/nexus-fremen-cunning.ts b/game/nexus-fremen-cunning.ts
new file mode 100644
index 00000000..77bfc8a4
--- /dev/null
+++ b/game/nexus-fremen-cunning.ts
@@ -0,0 +1,169 @@
+import { TERRITORIES, splitLocation, validGameLocation } from './board';
+import type { Game, Player } from './engine';
+import { presenceAt } from './force-presence';
+import { nexusCardMode, validateNexusCards } from './nexus-cards';
+import { territoryEntryBlock } from './occupancy';
+
+/** The engine allocates a distinct event for each accepted worm appearance, even
+ * when two worms visit the same territory. Capture before any devouring. */
+export type FremenCunningOccurrence = Readonly<{
+ event: string;
+ turn: number;
+ territory: string;
+ origin: 'natural' | 'additional' | 'summoned';
+ pile?: 0 | 1;
+ parent?: string;
+ initiallyEmpty: boolean;
+}>;
+
+export type FremenCunningCounts = Readonly<{ normal: number; elite: number }>;
+export type FremenCunningSource = Readonly<{
+ territory: string;
+ sectors: Readonly>;
+}>;
+export type FremenCunningSelection = Readonly<{
+ source: string;
+ forces: Readonly>;
+ destination: Readonly<{ territory: string; sector: number }>;
+}>;
+export type FremenCunningQuote = Readonly<{
+ event: string;
+ sources: readonly FremenCunningSource[];
+}>;
+/** Persisted by the engine only after spending the owner's actual card.
+ * The caller must authenticate this receipt against its saved event history. */
+export type FremenCunningRideAuthorization = Readonly<{
+ event: string;
+ turn: number;
+ owner: string;
+}>;
+
+/** Call only from the accepted appearance branch, before wormSurvival/devour.
+ * The caller persists and authenticates this record; it is not a card-draw hook. */
+export function captureFremenCunningOccurrence(
+ g: Game,
+ occurrence: Omit,
+): FremenCunningOccurrence {
+ if (g.status !== 'playing' || g.phase !== 1 || !occurrence.event ||
+ !['natural', 'additional', 'summoned'].includes(occurrence.origin) ||
+ !TERRITORIES.some(t => t.id === occurrence.territory) ||
+ (occurrence.origin === 'summoned'
+ ? !occurrence.parent || g.summonedWorm?.event !== occurrence.parent ||
+ g.summonedWorm?.territory !== occurrence.territory
+ : occurrence.parent !== undefined))
+ throw new Error('Capture Cunning at an actual Arrakis worm appearance.');
+ return {
+ ...occurrence,
+ turn: g.turn,
+ initiallyEmpty: g.players.every(p => presenceAt(p, occurrence.territory) === 0),
+ };
+}
+
+function physicalDesertSources(g: Game, p: Player): FremenCunningSource[] {
+ const sources = new Map>();
+ for (const [key, total] of Object.entries(p.forces)) {
+ if (!Number.isSafeInteger(total) || total <= 0) continue;
+ const { territory: id, sector } = splitLocation(key);
+ const terrain = TERRITORIES.find(t => t.id === id);
+ if (terrain?.type !== 'sand' || !validGameLocation(g, id, sector) || sector === g.storm) continue;
+ const elite = p.elites?.forces[key] ?? 0;
+ if (!Number.isSafeInteger(elite) || elite < 0 || elite > total)
+ throw new Error('Cunning source has invalid physical force custody.');
+ const sectors = sources.get(id) ?? {};
+ sectors[key] = { normal: total - elite, elite };
+ sources.set(id, sectors);
+ }
+ return [...sources].map(([territory, sectors]) => ({ territory, sectors }));
+}
+
+/** Null means this seat/appearance cannot use native Fremen Cunning. No
+ * post-devour presence check may replace the occurrence's original fact. */
+export function quoteFremenCunning(
+ g: Game, owner: string, occurrence: FremenCunningOccurrence,
+): FremenCunningQuote | null {
+ const p = g.players.find(player => player.id === owner);
+ const cards = g.nexusCards?.cards;
+ if (!p || g.status !== 'playing' || occurrence.turn !== g.turn ||
+ !occurrence.event || !occurrence.initiallyEmpty ||
+ !TERRITORIES.some(t => t.id === occurrence.territory) ||
+ (occurrence.origin === 'summoned' &&
+ (!occurrence.parent || g.summonedWorm?.event !== occurrence.parent ||
+ g.summonedWorm?.territory !== occurrence.territory)) ||
+ p.faction !== 'fremen' || p.ally ||
+ cards?.hands[owner] !== 'fremen' ||
+ nexusCardMode('fremen', p.faction, g.players.map(seat => seat.faction)) !== 'cunning') return null;
+ validateNexusCards(cards, g.players);
+ const sources = physicalDesertSources(g, p);
+ return sources.length ? { event: occurrence.event, sources } : null;
+}
+
+/** A spent card is in the physical discard, not the rider's hand. The engine
+ * must verify this authorization is its own saved receipt, never action input. */
+export function quoteFremenCunningRide(
+ g: Game, owner: string, occurrence: FremenCunningOccurrence,
+ authorization: FremenCunningRideAuthorization,
+): FremenCunningQuote | null {
+ const p = g.players.find(player => player.id === owner);
+ const cards = g.nexusCards?.cards;
+ if (!p || p.faction !== 'fremen' || g.status !== 'playing' ||
+ !occurrence.initiallyEmpty || !occurrence.event || occurrence.turn !== g.turn ||
+ !TERRITORIES.some(t => t.id === occurrence.territory) ||
+ (occurrence.origin === 'summoned' && !occurrence.parent) ||
+ !authorization || authorization.event !== occurrence.event ||
+ authorization.turn !== occurrence.turn || authorization.owner !== owner ||
+ !cards?.discard.includes('fremen') || cards.hands[owner] === 'fremen')
+ return null;
+ validateNexusCards(cards, g.players);
+ const sources = physicalDesertSources(g, p);
+ return sources.length ? { event: occurrence.event, sources } : null;
+}
+
+/** Same structural, storm and occupation restrictions as ordinary riding.
+ * Dynamic Homeworld/Discovery/HMS arrival needs its own dedicated adapter. */
+export function fremenCunningDestinationBlock(
+ g: Game, owner: string, occurrence: FremenCunningOccurrence,
+ source: string, destination: FremenCunningSelection['destination'],
+): string | null {
+ const { territory: to, sector } = destination;
+ if (!TERRITORIES.some(t => t.id === to) || !validGameLocation(g, to, sector))
+ return 'Choose an Arrakis board sector.';
+ if (to === source)
+ return 'Choose another destination territory.';
+ if (sector !== 0 && sector === g.storm) return 'That sector is in storm.';
+ return territoryEntryBlock(g.players, owner, to);
+}
+
+/** Requote immediately before committing, while the authenticated appearance
+ * remains pending. This only validates; the engine performs typed movement. */
+export function validateFremenCunningSelection(
+ g: Game, owner: string, occurrence: FremenCunningOccurrence,
+ selection: FremenCunningSelection,
+ authorization: FremenCunningRideAuthorization,
+): Readonly<{ total: number; elite: number }> {
+ const quote = quoteFremenCunningRide(g, owner, occurrence, authorization);
+ if (!quote) throw new Error('Fremen Cunning is not available for this appearance.');
+ const source = quote.sources.find(row => row.territory === selection.source);
+ if (!source || !selection.forces || typeof selection.forces !== 'object' ||
+ Array.isArray(selection.forces)) throw new Error('Choose one occupied desert territory.');
+ let total = 0, elite = 0;
+ for (const [key, count] of Object.entries(selection.forces)) {
+ const available = Object.hasOwn(source.sectors, key) ? source.sectors[key] : undefined;
+ if (!available || !count || Object.keys(count).sort().join(',') !== 'elite,normal' ||
+ !Number.isSafeInteger(count.normal) || !Number.isSafeInteger(count.elite) ||
+ count.normal < 0 || count.elite < 0 ||
+ count.normal > available.normal || count.elite > available.elite)
+ throw new Error('Choose available typed physical forces in the source territory.');
+ total += count.normal + count.elite;
+ elite += count.elite;
+ }
+ if (!Number.isSafeInteger(total) || !total)
+ throw new Error('Choose at least one physical force to ride.');
+ if (!selection.destination || typeof selection.destination.territory !== 'string' ||
+ !Number.isSafeInteger(selection.destination.sector))
+ throw new Error('Choose an Arrakis destination sector.');
+ const blocked = fremenCunningDestinationBlock(
+ g, owner, occurrence, selection.source, selection.destination,
+ );
+ if (blocked) throw new Error(blocked);
+ return { total, elite };
+}
diff --git a/game/reference.ts b/game/reference.ts
index d59b45a4..5aa67823 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -480,7 +480,7 @@ export const RULE_TOPICS: RuleTopic[] = [
summary:
'Choose an available order change or become battle aggressor before plans, then discard Sapho. Later intervention and other timing modes remain unfinished.',
steps: [
- 'In a current Once Around auction, first is available before anyone bids. Last is available before your own bid, including after Richese. You still get only one bidding opportunity and must outbid the current high bid to win.',
+ 'In a current Once Around auction, first or last can reorder remaining bidders while you have not yet bid. Earlier bids stay committed; a position you already hold is unavailable. You still get only one bidding opportunity and must outbid the current high bid to win.',
'Discarding Sapho frees a hand slot. A full-hand holder can join a still-open lot through this order change. A completed bid or pass cannot be repeated, and a finished lot cannot reopen.',
'The movement controls change complete shipment and movement turns at a boundary before the current player begins. First moves you ahead of the remaining unstarted turns, including after earlier players have fully finished. In Advanced, the Guild must have finished or be absent. Last stays after the Guild even if the Guild later chooses to wait.',
'Between completed battles, choose first or last among remaining battle choosers. The priority lasts for the rest of the Battle phase. You still need a real unresolved battle. Others may choose battles against you before your own turn; last is not immunity. This scope changes the chooser, not each battle’s aggressor or tie advantage.',
@@ -4008,12 +4008,14 @@ export const RULE_TOPICS: RuleTopic[] = [
'Bene Gesserit Secret Ally borrowed Voice remains unavailable pending its cancellation and timing interpretation. Its printed permission does not make the other unfinished effects available.',
'Emperor Cunning requires an Advanced battle with at least five ordinary Emperor counters and no actual Sardaukar, before submitting your plan. The played Nexus card opens one Karama response. Allowing it makes five ordinary counters count as Sardaukar for this battle; cancellation prevents the entire enhancement and leaves the Nexus card spent.',
'Temporary Sardaukar use the applicable strength and support rules, including the Fremen exception, but remain physically ordinary counters. Their losses go to Tanks as ordinary counters, without changing starred reserves or Tanks. Existing inspected or promised plans remain binding. The role ends after this battle; fewer-than-five groups remain unavailable. The separate Secret Ally revival has its own connected controls; other Emperor modes remain unfinished.',
- 'With Fremen absent, an unallied holder may spend Fremen Secret Ally during Revival to return exactly three eligible forces from Tanks to reserves at no spice cost. Unlike Emperor Secret Ally, these three consume the ordinary force and free-force allowances, including the Advanced one-elite cap. This first path requires base factions and Nexus alone, before any normal force returns, without combined revival effects. Partial returns, prior returns, higher free rates and the separate sandworm protection and Cunning effects remain unavailable.',
+ 'With Fremen absent, an unallied holder may spend Fremen Secret Ally during Revival to return exactly three eligible forces from Tanks to reserves at no spice cost. Unlike Emperor Secret Ally, these three consume the ordinary force and free-force allowances, including the Advanced one-elite cap. This first path requires base factions and Nexus alone, before any normal force returns, without combined revival effects. Partial returns, prior returns, higher free rates and separate sandworm protection remain unavailable.',
+ 'Native Fremen Cunning has a bounded Nexus-only path: when a natural, accepted additional, or Advanced special-Karama summoned worm appears in a territory initially containing no forces, Fremen may spend their own Nexus card before the worm resolves. The special summon retains its original Spice Blow parent. After the applicable Nexus, choose some forces from one occupied desert territory and ride to a legal Arrakis destination without using normal movement. The choice appears even without the secret card, so declining does not disclose custody. Karama provisionally prevents only the additional remote ride; the original worm still resolves and the card remains spent. An interrupted existing Cunning control is restored, and a selected ride waits for the summoned Nexus without granting a second Cunning offer; Great Maker and combined optional modules await integration.',
'CHOAM Secret Ally has a working Collection trade: when CHOAM is absent, an unallied holder may discard one Worthless card and the Nexus card for two bank spice. Its after-victory inspection remains unfinished.',
'CHOAM Cunning spends its Nexus card to use one held Treachery Card for one chosen Worthless effect at that effect’s normal timing, in Basic and Advanced. Choose the physical card separately from the effect. Kulon, La La La, Baliset, Jubba Cloak and Trip to Gamont are supported; Kull Wahad’s Karama-prevention reaction remains unavailable.',
'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.',
'With Moritani seated, an unallied holder of its Nexus card may spend Betrayal at a clean play boundary to return one placed Terror token to hidden Moritani supply without revealing its face. Choose the public location and token, not its secret effect; this does not use Moritani’s Mentat placement or trigger that token.',
+ 'Ecaz Betrayal is available to an unallied holder with Ecaz seated and reciprocally allied in a shared Arrakis territory. At the opening of Shipment and Movement, choose one such territory; the ally returns its entire normal and starred force group from every sector there to reserves, leaving Ecaz forces in place. The Nexus card is spent before a Karama response. Cancellation preserves the board but not the card. Only the base/Ecaz Nexus roster without combined modules is supported; Duke Cunning and Secret Ally inquiry remain unavailable.',
'When Richese is absent, its Secret Ally replaces your ordinary reserve shipment with up to five physical forces charged as one. Select the explicit shipment checkbox; forces, elite identity, destination restrictions, Homeworld reserve sources and normal movement remain unchanged. Fremen remain free within their normal reinforcement range; Guild and an active Karama rate retain their existing tariff and payment routing. This is not a concealed No-Field or an extra shipment.',
'Guild Cunning is declared when finishing your ordinary Shipment and Movement turn. It spends the Nexus and opens one Karama response. Allowance grants a second native shipment at normal Guild prices, including supported cross-shipment, return and Homeworld routes. Cancellation leaves the completed first turn intact. Only an unused Hajr move can follow the second shipment; no ordinary movement or third move is granted. The physical Ornithopter card combination remains unavailable.',
'With Guild absent, its Secret Ally is an explicit optional source for one ordinary shipment at Guild prices. It supports physical reserve shipment, cross-shipment and return to abstract reserves, plus half-price independently legal world-to-world shipment. Fremen may retain free reinforcement or choose the paid card route beyond its normal range, outside the storm. Native faction, typed sources and normal movement stay unchanged. Homeworld reserve return awaits a ruling; arbitrary foreign-world transport and concealed No-Fields are not granted.',
@@ -4022,11 +4024,11 @@ export const RULE_TOPICS: RuleTopic[] = [
],
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, Fremen Secret Ally ordinary free-three revival in a bounded base-faction path, five source-aware CHOAM Cunning effects, Moritani Cunning placement and Betrayal token return, and Richese Secret Ally reserve shipping priced as one. Borrowed Voice, other Betrayal reactions, Tleilaxu revival, Fremen worm protection/Cunning 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/nexus-fremen-revival.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/engine.ts'] },
- { area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation, advisor selection, temporary Sardaukar and CHOAM effects with native Karama responses, Moritani Betrayal token selection, and Richese shipment pricing. 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', 'components/nexus-moritani-betrayal.tsx'] },
- { area: 'AI', status: 'Partial', detail: 'All profiles handle integrated effects through private offers, including Moritani Betrayal public-token selection. 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-moritani-betrayal-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 include Moritani Betrayal; Emperor Cunning browser checks pass while complete module games remain separate.', 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-moritani-betrayal.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'] },
+ { 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, bounded Fremen Secret Ally free-three revival and native empty-worm Cunning, five source-aware CHOAM Cunning effects, Moritani Cunning placement and Betrayal token return, Ecaz Betrayal allied-force return, and Richese Secret Ally reserve shipping priced as one. Borrowed Voice, other Betrayal reactions, Tleilaxu revival, Fremen worm protection and combined Cunning interactions remain 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/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/engine.ts'] },
+ { area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation, advisor selection, temporary Sardaukar and CHOAM effects with native Karama responses, Moritani Betrayal token selection, Ecaz Betrayal shared-territory choice, Richese shipment pricing and Fremen Cunning offer/remote ride. 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', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/game-table.tsx'] },
+ { area: 'AI', status: 'Partial', detail: 'All profiles handle integrated effects through private offers, including Moritani Betrayal public-token selection, Ecaz Betrayal public-territory selection and legal Fremen Cunning offer/ride decisions. 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-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-richese-options.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts'] },
+ { area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and integrated effect families retain explicit unresolved boundaries, including Ecaz Duke/inquiry, a second Cunning offer during an interrupted Fremen control, Great Maker and combined modules, 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_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md'] },
+ { area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, support solver, territory-selection controls, bot and in-memory SQLite recovery regressions include Moritani Betrayal, bounded Fremen Cunning with a genuine special summon, and Ecaz Betrayal. Local natural and summoned Fremen offer/ride and Ecaz holder/Karama response browser surfaces were checked with isolated fixture views without a room write. Full module games and combined interactions remain separate.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-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 e9f01f8c..28e2a419 100644
--- a/game/richese-cancellation.ts
+++ b/game/richese-cancellation.ts
@@ -76,6 +76,8 @@ const DECISIONS = {
greatMakerVote: true,
greatMakerRide: true,
wormRide: true,
+ nexusFremenCunningOffer: true,
+ nexusFremenCunningRide: true,
moritaniRetention: true,
battleCards: true,
} satisfies Record;
@@ -92,6 +94,8 @@ const RESPONSES = {
nexusAdvisorFlip: true,
nexusSardaukar: true,
nexusGuildCunning: true,
+ nexusFremenCunning: true,
+ nexusEcazBetrayal: true,
advisor: true,
emperorIncome: true,
richesePurchaseIncome: true,
diff --git a/lib/build-revision.ts b/lib/build-revision.ts
new file mode 100644
index 00000000..60a2e0ca
--- /dev/null
+++ b/lib/build-revision.ts
@@ -0,0 +1,9 @@
+declare const __BUILD_REVISION__: string;
+
+/** The Git commit baked into this build, or 'unavailable' without a checkout/SHA. */
+export const buildRevision = typeof __BUILD_REVISION__ === 'string'
+ ? __BUILD_REVISION__
+ : 'unavailable';
+export const buildRevisionLabel = buildRevision === 'unavailable'
+ ? 'unavailable'
+ : buildRevision.slice(0, 7);
diff --git a/tests/fixture-nexus-cards.ts b/tests/fixture-nexus-cards.ts
index 00009d95..8b171a93 100644
--- a/tests/fixture-nexus-cards.ts
+++ b/tests/fixture-nexus-cards.ts
@@ -24,6 +24,7 @@ export function nexusFixture(
seatIds?: [string, string, string];
advanced?: boolean;
hostFaction?: 'fremen' | 'moritani' | 'ecaz' | 'guild' | 'emperor';
+ secondFaction?: 'atreides' | 'emperor' | 'beneGesserit';
} = {},
): Game {
const [f, a, h] = options.seatIds ?? ['f', 'a', 'h'];
@@ -37,7 +38,9 @@ export function nexusFixture(
options.advanced ?? false,
[],
);
- joinGame(g, newPlayer(a, 'Atreides', 'atreides'));
+ joinGame(g, newPlayer(a, options.secondFaction === 'emperor' ? 'Emperor' :
+ options.secondFaction === 'beneGesserit' ? 'Bene Gesserit' : 'Atreides',
+ options.secondFaction ?? 'atreides'));
joinGame(g, newPlayer(h, 'Harkonnen', 'harkonnen'));
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
g.nexusCards = { cards: null, phase: null };
@@ -128,6 +131,11 @@ export function finishNexusSpice(state: Game): Game {
type: 'decision',
accept: false,
});
+ else if (g.decision?.kind === 'nexusFremenCunningOffer' ||
+ g.decision?.kind === 'nexusFremenCunningRide')
+ g = applyAction(g, g.decision.player, {
+ type: 'decision', event: g.decision.event, accept: false,
+ });
else {
assert.equal(g.decision, null);
g = nexusReady(g);
@@ -141,6 +149,7 @@ export function nexusTurnTwo(
seatIds?: [string, string, string];
advanced?: boolean;
hostFaction?: 'fremen' | 'moritani' | 'ecaz' | 'guild' | 'emperor';
+ secondFaction?: 'atreides' | 'emperor' | 'beneGesserit';
} = {},
): Game {
const first = enterNexusSpice(nexusFixture(options));
diff --git a/tests/nexus-ecaz-betrayal-quotes.test.ts b/tests/nexus-ecaz-betrayal-quotes.test.ts
new file mode 100644
index 00000000..100f8a60
--- /dev/null
+++ b/tests/nexus-ecaz-betrayal-quotes.test.ts
@@ -0,0 +1,183 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import type { Game } from '../game/engine';
+import {
+ ecazBetrayalOffer, quoteEcazBetrayal, validateEcazBetrayalSnapshot,
+ type EcazBetrayalSnapshot,
+} from '../game/nexus-ecaz-betrayal';
+import { nexusFixture, nexusReload } from './fixture-nexus-cards';
+
+/** Ordinary printed-board occupation; no other expansion or optional modules. */
+function position() {
+ const g = nexusFixture({ hostFaction: 'ecaz', advanced: true });
+ const ecaz = g.players.find(p => p.faction === 'ecaz')!;
+ const ally = g.players.find(p => p.faction === 'atreides')!;
+ const holder = g.players.find(p => p.faction === 'harkonnen')!;
+ // The fixture seats only three factions. Model the supported Emperor's
+ // advanced Sardaukar as a genuinely typed physical ally group.
+ ally.faction = 'emperor';
+ ally.forces = { 'wind_pass:14': 3, 'wind_pass:15': 2, 'carthag:11': 1 };
+ ally.reserves = 14;
+ ally.elites = { reserves: 2, tanks: 0, forces: {
+ 'wind_pass:14': 1, 'wind_pass:15': 1, 'carthag:11': 1,
+ }, revived: 0 };
+ ecaz.forces = { 'wind_pass:14': 2, 'carthag:11': 1 };
+ ecaz.reserves = 17;
+ ecaz.ally = ally.id;
+ ally.ally = ecaz.id;
+ const cards = g.nexusCards!.cards!;
+ const cardIndex = cards.deck.indexOf('ecaz');
+ assert.ok(cardIndex >= 0);
+ cards.hands[holder.id] = cards.deck.splice(cardIndex, 1)[0];
+ g.phase = 5;
+ g.order = [ecaz.id, holder.id, ally.id];
+ g.active = g.order[0];
+ g.movementRemaining = [...g.order];
+ g.response = null;
+ g.decision = null;
+ g.phaseOpening = null;
+ g.nexusCards!.phase = null;
+ for (const player of g.players) {
+ player.shipped = false;
+ player.moved = 0;
+ }
+ return { g, ecaz: ecaz.id, ally: ally.id, holder: holder.id };
+}
+function editGame(g: Game, change: (copy: Game) => void) {
+ const copy = nexusReload(g);
+ change(copy);
+ return copy;
+}
+
+void test('Ecaz Betrayal quotes one selected public territory with every typed sector and no opponent data', () => {
+ const { g, ecaz, ally, holder } = position();
+ const offer = ecazBetrayalOffer(g, holder)!;
+ assert.equal(offer.blocked, null);
+ assert.equal(offer.ally, ally);
+ assert.deepEqual(offer.territories.map(row => row.territory), ['wind_pass', 'carthag']);
+ assert.deepEqual(offer.territories[0], { territory: 'wind_pass', sectors: {
+ 'wind_pass:14': { normal: 2, elite: 1 },
+ 'wind_pass:15': { normal: 1, elite: 1 },
+ }, total: { normal: 3, elite: 2 } });
+ assert.deepEqual(offer.territories[1], { territory: 'carthag', sectors: {
+ 'carthag:11': { normal: 0, elite: 1 },
+ }, total: { normal: 0, elite: 1 } });
+ const quote = quoteEcazBetrayal(g, holder, offer.event, 'wind_pass');
+ assert.deepEqual(quote, { event: offer.event, owner: holder, ally, turn: g.turn,
+ discardIndex: g.nexusCards!.cards!.discard.length, ...offer.territories[0] });
+ assert.doesNotThrow(() => validateEcazBetrayalSnapshot(g, quote));
+ assert.equal(ecazBetrayalOffer(g, ecaz), null);
+ assert.equal(ecazBetrayalOffer(g, ally), null);
+ assert.equal(ecazBetrayalOffer(g, 'not-seated'), null);
+ const alteredPrivateAndOpponent = editGame(g, copy => {
+ copy.players.find(p => p.id === ecaz)!.traitors = ['private Ecaz traitor'];
+ copy.players.find(p => p.id === holder)!.traitors = ['private holder traitor'];
+ copy.players.find(p => p.id === holder)!.forces['carthag:11'] += 2;
+ });
+ assert.deepEqual(ecazBetrayalOffer(alteredPrivateAndOpponent, holder), offer);
+});
+
+void test('Ecaz Betrayal needs reciprocal native alliance, cooccupation, proper card custody and simple roster', () => {
+ const { g, ecaz, ally, holder } = position();
+ assert.match(ecazBetrayalOffer(editGame(g, copy => { copy.players.find(p => p.id === ally)!.ally = null; }), holder)!.blocked!, /reciprocal/);
+ assert.match(ecazBetrayalOffer(editGame(g, copy => { copy.players.find(p => p.id === ecaz)!.ally = null; }), holder)!.blocked!, /reciprocal/);
+ assert.match(ecazBetrayalOffer(editGame(g, copy => { copy.players.find(p => p.id === holder)!.ally = ally; }), holder)!.blocked!, /allied player/);
+ assert.match(ecazBetrayalOffer(editGame(g, copy => { copy.players.find(p => p.id === ecaz)!.forces = {}; }), holder)!.blocked!, /share no territory/);
+ assert.match(ecazBetrayalOffer(editGame(g, copy => { copy.players.find(p => p.id === ally)!.forces = {}; copy.players.find(p => p.id === ally)!.elites!.forces = {}; }), holder)!.blocked!, /share no territory/);
+ assert.equal(ecazBetrayalOffer(editGame(g, copy => { copy.nexusCards!.cards!.hands[holder] = null; }), holder), null);
+ assert.match(ecazBetrayalOffer(editGame(g, copy => { copy.players.find(p => p.id === holder)!.faction = 'richese'; }), holder)!.blocked!, /Combined modules/);
+ assert.match(ecazBetrayalOffer(editGame(g, copy => { copy.homeworlds = { custody: null }; }), holder)!.blocked!, /Combined modules/);
+ assert.match(ecazBetrayalOffer(editGame(g, copy => { copy.players.find(p => p.id === ally)!.advisors = { wind_pass: {} }; }), holder)!.blocked!, /advisor/);
+ assert.match(ecazBetrayalOffer(editGame(g, copy => { copy.expansions.push('ix'); }), holder)!.blocked!, /Combined modules/);
+});
+
+void test('opening ends with any shipment, movement, first-seat pass, pending decision or automatic continuation', () => {
+ const { g, holder } = position();
+ const blocked = (change: (copy: Game) => void) => {
+ const reason = ecazBetrayalOffer(editGame(g, change), holder)!.blocked;
+ assert.ok(reason, 'The opening action must be unavailable.');
+ return reason;
+ };
+ assert.match(blocked(copy => { copy.players[0].shipped = true; }), /before the first/);
+ assert.match(blocked(copy => { copy.players[1].moved = 1; }), /before the first/);
+ assert.match(blocked(copy => { copy.movementRemaining!.shift(); copy.active = copy.order[1]; }), /before the first/);
+ assert.match(blocked(copy => { copy.movementRemaining!.shift(); copy.active = copy.order[0]; }), /before the first/);
+ assert.match(blocked(copy => { copy.active = null; }), /before the first/);
+ assert.match(blocked(copy => { copy.phase = 4; }), /before the first/);
+ assert.match(blocked(copy => { copy.phaseOpening = { passed: [], initialize: true }; }), /current interaction/);
+ assert.match(blocked(copy => { copy.decision = { kind: 'guildTiming', player: holder, next: g.order[0], following: g.order[1] }; }), /current interaction/);
+ assert.match(ecazBetrayalOffer(g, holder, true)!.blocked!, /current interaction/);
+});
+
+void test('accepted quote rejects foreign territory, stale event and changing typed board custody', () => {
+ const { g, ecaz, ally, holder } = position();
+ const event = ecazBetrayalOffer(g, holder)!.event;
+ assert.throws(() => quoteEcazBetrayal(g, ecaz, event, 'wind_pass'));
+ assert.throws(() => quoteEcazBetrayal(g, holder, 'stale', 'wind_pass'), /stale/);
+ assert.throws(() => quoteEcazBetrayal(g, holder, event, 'polar_sink'), /shared territory/);
+ const quote = quoteEcazBetrayal(g, holder, event, 'wind_pass');
+ assert.throws(() => validateEcazBetrayalSnapshot(editGame(g, copy => { copy.players.find(p => p.id === ally)!.forces['wind_pass:15']--; }), quote), /original physical ally group/);
+ assert.throws(() => validateEcazBetrayalSnapshot(editGame(g, copy => { copy.turn++; }), quote));
+ assert.throws(() => validateEcazBetrayalSnapshot(editGame(g, copy => { copy.nexusCards!.cards!.discard.push('guild'); }), quote));
+ assert.throws(() => validateEcazBetrayalSnapshot(g, { ...quote, ally: holder }), /original physical ally group/);
+ assert.throws(() => validateEcazBetrayalSnapshot(g, { ...quote, territory: 'carthag' }), /invalid saved sector group/);
+ assert.throws(() => validateEcazBetrayalSnapshot(g, { ...quote, total: { normal: 4, elite: 2 } }), /original physical ally group/);
+});
+
+void test('malformed sector groups, inherited keys and invalid physical counts cannot become historical snapshots', () => {
+ const { g, holder, ally } = position();
+ const event = ecazBetrayalOffer(g, holder)!.event;
+ const quote = quoteEcazBetrayal(g, holder, event, 'wind_pass');
+ const bad = (patch: Partial) =>
+ assert.throws(() => validateEcazBetrayalSnapshot(g, { ...quote, ...patch }));
+ bad({ sectors: Object.assign(Object.create({ 'wind_pass:15': { normal: 1, elite: 1 } }), {
+ 'wind_pass:14': { normal: 2, elite: 1 },
+ }) });
+ bad({ sectors: { 'wind_pass:14': { normal: 2, elite: 1 },
+ 'carthag:11': { normal: 1, elite: 1 } } });
+ bad({ sectors: { 'wind_pass:14': { normal: -1, elite: 4 },
+ 'wind_pass:15': { normal: 1, elite: 1 } } });
+ bad({ sectors: { 'wind_pass:14': { normal: 2.5, elite: 1 },
+ 'wind_pass:15': { normal: 1, elite: 1 } } });
+ bad({ sectors: { 'wind_pass:14': { normal: 2, elite: 1, secret: 7 } as never,
+ 'wind_pass:15': { normal: 1, elite: 1 } } });
+ const inherited = nexusReload(g);
+ const inheritedAlly = inherited.players.find(p => p.id === ally)!;
+ inheritedAlly.forces = Object.assign(Object.create({ 'wind_pass:13': 1 }), inheritedAlly.forces);
+ assert.throws(() => ecazBetrayalOffer(inherited, holder), /plain physical/);
+ assert.throws(() => ecazBetrayalOffer(editGame(g, copy => {
+ copy.players.find(p => p.id === ally)!.elites!.forces['wind_pass:14'] = 9;
+ }), holder), /typed force/);
+ assert.throws(() => ecazBetrayalOffer(editGame(g, copy => {
+ copy.players.find(p => p.id === ally)!.forces['wind_pass:15'] = NaN;
+ }), holder), /board custody/);
+});
+
+void test('pending response validates the exact captured group after physical card discard without rereading a now-empty hand', () => {
+ const { g, holder, ally } = position();
+ const event = ecazBetrayalOffer(g, holder)!.event;
+ const snapshot = quoteEcazBetrayal(g, holder, event, 'wind_pass');
+ const pending = editGame(g, copy => {
+ copy.nexusCards!.cards!.hands[holder] = null;
+ copy.nexusCards!.cards!.discard.push('ecaz');
+ });
+ assert.doesNotThrow(() => validateEcazBetrayalSnapshot(pending, snapshot, true));
+ const anotherSpentCard = editGame(pending, copy => {
+ const cards = copy.nexusCards!.cards!;
+ const index = cards.deck.indexOf('guild');
+ assert.ok(index >= 0);
+ cards.discard.push(cards.deck.splice(index, 1)[0]);
+ });
+ assert.doesNotThrow(() => validateEcazBetrayalSnapshot(anotherSpentCard, snapshot, true),
+ 'A separately spent Nexus card cannot invalidate this pending Ecaz return.');
+ assert.throws(() => validateEcazBetrayalSnapshot(pending, snapshot));
+ assert.throws(() => validateEcazBetrayalSnapshot(editGame(pending, copy => {
+ copy.players.find(p => p.id === ally)!.elites!.forces['wind_pass:14'] = 0;
+ }), snapshot, true), /original physical ally group/);
+ assert.throws(() => validateEcazBetrayalSnapshot(editGame(pending, copy => {
+ copy.nexusCards!.cards!.discard.push('guild');
+ }), snapshot, true), /Nexus/);
+ assert.throws(() => validateEcazBetrayalSnapshot(pending,
+ { ...snapshot, discardIndex: snapshot.discardIndex + 1 }, true), /pending card/);
+ assert.throws(() => validateEcazBetrayalSnapshot(pending, { ...snapshot, event: 'forged' }, true), /pending card/);
+});
diff --git a/tests/nexus-ecaz-betrayal-recovery.test.ts b/tests/nexus-ecaz-betrayal-recovery.test.ts
new file mode 100644
index 00000000..1ded7f76
--- /dev/null
+++ b/tests/nexus-ecaz-betrayal-recovery.test.ts
@@ -0,0 +1,275 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import type { DatabaseSync } from 'node:sqlite';
+import { applyAction, viewGame, type Action, type Game } from '../game/engine';
+import type * as Rooms from '../db/rooms';
+import type { RoomsClock, SeatAuth } from '../db/rooms';
+import {
+ finishNexusSpice, nexusAllow, nexusInventory, nexusPlayer, nexusReady,
+ nexusTurnTwo, orderNexusSpice,
+} from './fixture-nexus-cards';
+import { unitStore } from './fixture-nexus-room-store';
+
+const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
+
+async function fixture(t: test.TestContext): Promise {
+ const store = unitStore();
+ t.after(() => store.sqlite.close());
+ const made = await store.rooms.createRoom('Ecaz Betrayal SQL', 'ecaz', true, ['ecaz']);
+ const code = made.view.code;
+ const tokens = [made.token];
+ for (const faction of ['emperor', 'harkonnen'] as const)
+ tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
+ const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token)));
+ const ids = auths.map(auth => auth.playerId) as [string, string, string];
+ const [ecaz, ally, owner] = ids;
+
+ let g = nexusTurnTwo({ seatIds: ids, hostFaction: 'ecaz', secondFaction: 'emperor', advanced: true });
+ orderNexusSpice(g, ['worm', 'land', 'land']);
+ g = nexusReady(nexusReady(g));
+ g = applyAction(g, ecaz, { type: 'alliance', target: ally });
+ g = applyAction(g, ally, { type: 'alliance', target: ecaz });
+ assert.equal(nexusPlayer(g, ecaz).ally, ally);
+ assert.equal(nexusPlayer(g, ally).ally, ecaz);
+ g = finishNexusSpice(g);
+ assert.equal(g.nexusCards!.phase!.stage, 'drawing');
+ const cards = g.nexusCards!.cards!;
+ const index = cards.deck.indexOf('ecaz');
+ assert.ok(index >= 0);
+ cards.deck.splice(index, 1);
+ cards.deck.unshift('ecaz');
+ g = applyAction(g, owner, {
+ type: 'nexusCardChoice', turn: g.turn, card: null, choice: 'draw', ownRedraws: 0,
+ });
+ assert.equal(g.nexusCards!.cards!.hands[owner], 'ecaz');
+ g = nexusReady(g);
+ for (let step = 0; g.phase === 3 && step < 30; step++) {
+ if (g.phaseOpening) {
+ const id = g.players.find(p => !g.phaseOpening!.passed.includes(p.id))!.id;
+ g = applyAction(g, id, { type: 'ready' });
+ } else if (g.auction) {
+ g = applyAction(g, g.auction.active, { type: 'passBid' });
+ } else g = nexusReady(g);
+ }
+ assert.equal(g.phase, 4);
+ for (let step = 0; g.phase === 4 && step < 10; step++) {
+ if (g.decision?.kind === 'ecazPlacement')
+ g = applyAction(g, g.decision.player, { type: 'decision', decline: true });
+ else if (g.response) g = nexusAllow(g);
+ else g = nexusReady(g);
+ }
+ assert.equal(g.phase, 5);
+ assert.equal(g.active, g.order[0]);
+ assert.deepEqual(g.movementRemaining, g.order);
+
+ // Move existing physical Emperor counters, including Sardaukar, not minted ones.
+ const target = nexusPlayer(g, ally);
+ target.reserves += Object.values(target.forces).reduce((sum, n) => sum + n, 0);
+ target.elites!.reserves += Object.values(target.elites!.forces).reduce((sum, n) => sum + n, 0);
+ target.forces = { 'wind_pass:14': 3, 'wind_pass:15': 2, 'carthag:11': 1 };
+ target.elites!.forces = { 'wind_pass:14': 1, 'wind_pass:15': 1 };
+ target.reserves -= 6;
+ target.elites!.reserves -= 2;
+ const native = nexusPlayer(g, ecaz);
+ native.reserves -= 2;
+ native.forces['wind_pass:14'] = (native.forces['wind_pass:14'] ?? 0) + 2;
+ const karamaIndex = g.deck.findIndex(card => card.effect === 'karama');
+ assert.ok(karamaIndex >= 0);
+ const karama = g.deck.splice(karamaIndex, 1)[0];
+ native.hand.push(karama);
+ nexusInventory(g);
+ const offer = viewGame(g, owner).nexusEcazBetrayal!;
+ assert.equal(offer.blocked, null);
+ assert.equal(offer.ally, ally);
+ assert.deepEqual(offer.territories.find(row => row.territory === 'wind_pass'), {
+ territory: 'wind_pass',
+ sectors: {
+ 'wind_pass:14': { normal: 2, elite: 1 },
+ 'wind_pass:15': { normal: 1, elite: 1 },
+ },
+ total: { normal: 3, elite: 2 },
+ });
+ const action: Action = { type: 'nexusEcazBetrayal', event: offer.event, territory: 'wind_pass' };
+ g.code = code;
+ g.version = (await store.rooms.readRoom(code)).version;
+ store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
+ .run(JSON.stringify(g), g.version, code);
+ store.writes.length = 0;
+ return { ...store, code, tokens, auths, ids, ecaz, ally, owner, karama: karama.id,
+ action, initial: g };
+}
+interface Fixture {
+ code: string;
+ tokens: string[];
+ auths: SeatAuth[];
+ ids: [string, string, string];
+ ecaz: string;
+ ally: string;
+ owner: string;
+ karama: string;
+ action: Action;
+ initial: Game;
+ sqlite: DatabaseSync;
+ restart(): typeof Rooms;
+ hooks: { beforeWrite?: () => Promise };
+ writes: { expected: number; changes: number }[];
+}
+
+const row = (f: Fixture) => f.sqlite.prepare('SELECT state, version FROM rooms WHERE code = ?')
+ .get(f.code);
+
+async function restored(f: Fixture, g: Game) {
+ const saved = row(f);
+ for (const [i, token] of f.tokens.entries()) {
+ const rooms = f.restart();
+ const auth = await rooms.authenticate(f.code, token);
+ const view = await rooms.readSeatView(f.code, auth);
+ assert.deepEqual(view, viewGame(g, f.ids[i]));
+ if (i === 2 && g.nexusCards!.cards!.hands[f.owner] === 'ecaz') {
+ assert.equal(view.nexusEcazBetrayal?.blocked, null);
+ assert.equal(view.nexusEcazBetrayal?.ally, f.ally);
+ } else assert.equal(view.nexusEcazBetrayal, null);
+ assert.equal(view.nexusCards?.card, i === 2 ? g.nexusCards!.cards!.hands[f.owner] : null);
+ assert.equal(Object.hasOwn(view, 'nexusEcazBetrayalHistory'), false);
+ for (const rival of view.players.filter(player => player.id !== auth.playerId)) {
+ assert.equal('hand' in rival, false);
+ assert.equal('traitors' in rival, false);
+ }
+ }
+ await f.restart().continueRoomAutomatic(f.code, clock);
+ assert.deepEqual(row(f), saved);
+ nexusInventory(g);
+ for (const p of g.players)
+ if (p.elites) {
+ const initial = nexusPlayer(f.initial, p.id).elites!;
+ assert.equal(
+ p.elites.reserves + p.elites.tanks +
+ Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0),
+ initial.reserves + initial.tanks +
+ Object.values(initial.forces).reduce((sum, n) => sum + n, 0),
+ );
+ }
+}
+
+async function act(f: Fixture, index: number, action: Action) {
+ const current = await f.restart().readRoom(f.code);
+ await f.restart().act(f.code, f.auths[index], current.version, action, clock);
+ return f.restart().readRoom(f.code);
+}
+
+async function passKaramaHolder(f: Fixture) {
+ const current = await f.restart().readRoom(f.code);
+ assert.equal(current.response?.kind, 'nexusEcazBetrayal');
+ assert.ok(viewGame(current, f.ecaz).responseControls?.cancelCards.length);
+ const done = await act(f, 0, { type: 'passResponse' });
+ assert.equal(done.response, null);
+ return { done, count: 1 };
+}
+
+void test('SQLite concurrent Ecaz proposals spend one card; restart and allowance return both typed sectors once', async t => {
+ const f = await fixture(t);
+ await restored(f, f.initial);
+ let arrivals = 0;
+ let release!: () => void;
+ const gate = new Promise(resolve => { release = resolve; });
+ f.hooks.beforeWrite = async () => {
+ if (++arrivals === 2) release();
+ await gate;
+ };
+ // Both independent room-module instances yield on the actual SQL write hook.
+ let results: PromiseSettledResult[];
+ try {
+ results = await Promise.allSettled([0, 1].map(() =>
+ f.restart().act(f.code, f.auths[2], f.initial.version, f.action, clock)));
+ } finally {
+ delete f.hooks.beforeWrite;
+ }
+ assert.equal(arrivals, 2);
+ assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
+ assert.deepEqual(f.writes.map(write => write.changes).sort((a, b) => a - b), [0, 1]);
+ const pending = await f.restart().readRoom(f.code);
+ assert.equal(pending.version, f.initial.version + 1);
+ assert.equal(pending.response?.kind, 'nexusEcazBetrayal');
+ assert.equal(pending.nexusEcazBetrayalHistory?.length, 1);
+ assert.equal(pending.nexusEcazBetrayalHistory?.[0].stage, 'pending');
+ assert.equal(pending.nexusCards!.cards!.hands[f.owner], null);
+ assert.equal(pending.nexusCards!.cards!.discard.filter(card => card === 'ecaz').length, 1);
+ assert.deepEqual(nexusPlayer(pending, f.ally).forces, nexusPlayer(f.initial, f.ally).forces);
+ assert.deepEqual(nexusPlayer(pending, f.ally).elites, nexusPlayer(f.initial, f.ally).elites);
+ await restored(f, pending);
+ const saved = row(f);
+ await assert.rejects(f.restart().act(f.code, f.auths[2], f.initial.version,
+ f.action, clock), /table changed/i);
+ await assert.rejects(f.restart().act(f.code, f.auths[2], pending.version,
+ f.action, clock), /Ecaz|Nexus/);
+ assert.deepEqual(row(f), saved);
+
+ const { done, count } = await passKaramaHolder(f);
+ assert.equal(done.version, pending.version + count);
+ const initialAlly = nexusPlayer(f.initial, f.ally);
+ const returned = nexusPlayer(done, f.ally);
+ for (const key of ['wind_pass:14', 'wind_pass:15']) {
+ assert.equal(returned.forces[key], undefined);
+ assert.equal(returned.elites!.forces[key], undefined);
+ }
+ assert.equal(returned.forces['carthag:11'], 1);
+ assert.equal(returned.reserves, initialAlly.reserves + 5);
+ assert.equal(returned.elites!.reserves, initialAlly.elites!.reserves + 2);
+ assert.deepEqual(nexusPlayer(done, f.ecaz).forces, nexusPlayer(f.initial, f.ecaz).forces);
+ assert.equal(done.nexusEcazBetrayalHistory?.[0].stage, 'complete');
+ assert.equal(done.nexusEcazBetrayalHistory?.length, 1);
+ assert.equal(done.nexusCards!.cards!.discard.filter(card => card === 'ecaz').length, 1);
+ assert.equal(done.phase, 5);
+ assert.equal(done.active, f.initial.active);
+ assert.deepEqual(done.movementRemaining, f.initial.movementRemaining);
+ assert.ok(done.players.every(player => !player.shipped && player.moved === 0));
+ await restored(f, done);
+ const settled = row(f);
+ await assert.rejects(f.restart().act(f.code, f.auths[2], done.version,
+ f.action, clock), /Ecaz|Nexus/);
+ assert.deepEqual(row(f), settled);
+});
+
+void test('SQLite Karama response after restart spends both physical cards but leaves allied forces in place', async t => {
+ const f = await fixture(t);
+ await f.restart().act(f.code, f.auths[2], f.initial.version, f.action, clock);
+ const pending = await f.restart().readRoom(f.code);
+ assert.equal(pending.version, f.initial.version + 1);
+ assert.equal(pending.response?.kind, 'nexusEcazBetrayal');
+ await restored(f, pending);
+ let canceled = await act(f, 0, { type: 'card', card: f.karama, mode: 'cancel' });
+ assert.equal(canceled.version, pending.version + 1);
+ const afterCard = canceled.version;
+ assert.equal(canceled.nexusCards!.cards!.discard.filter(card => card === 'ecaz').length, 1);
+ assert.equal(canceled.discard.filter(card => card.id === f.karama).length, 1);
+ await restored(f, canceled);
+ let passes = 0;
+ while (canceled.response) {
+ assert.ok(passes++ < f.ids.length);
+ const index = f.ids.indexOf(canceled.players.find(p =>
+ !canceled.response!.passed.includes(p.id))!.id);
+ canceled = await act(f, index, { type: 'passResponse' });
+ }
+ assert.equal(canceled.version, afterCard + passes);
+ assert.equal(canceled.response, null);
+ assert.equal(canceled.nexusEcazBetrayalHistory?.length, 1);
+ assert.equal(canceled.nexusEcazBetrayalHistory?.[0].stage, 'complete');
+ for (const id of [f.ally, f.ecaz]) {
+ const source = nexusPlayer(f.initial, id);
+ const actual = nexusPlayer(canceled, id);
+ assert.deepEqual(actual.forces, source.forces);
+ assert.deepEqual(actual.elites, source.elites);
+ assert.equal(actual.reserves, source.reserves);
+ }
+ assert.equal(canceled.nexusCards!.cards!.hands[f.owner], null);
+ assert.equal(canceled.nexusCards!.cards!.discard.filter(card => card === 'ecaz').length, 1);
+ assert.equal(canceled.discard.filter(card => card.id === f.karama).length, 1);
+ assert.equal(canceled.phase, 5);
+ assert.equal(canceled.active, f.initial.active);
+ assert.deepEqual(canceled.movementRemaining, f.initial.movementRemaining);
+ await restored(f, canceled);
+ const saved = row(f);
+ await assert.rejects(f.restart().act(f.code, f.auths[2], canceled.version,
+ f.action, clock), /Ecaz|Nexus/);
+ assert.deepEqual(row(f), saved);
+});
diff --git a/tests/nexus-ecaz-betrayal.test.ts b/tests/nexus-ecaz-betrayal.test.ts
new file mode 100644
index 00000000..3e970eae
--- /dev/null
+++ b/tests/nexus-ecaz-betrayal.test.ts
@@ -0,0 +1,202 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ applyAction, normalizeAutomaticGame, viewGame, type Action, type Game,
+} from '../game/engine';
+import { botActions, runBots } from '../game/bots';
+import {
+ finishNexusSpice, nexusAllow, nexusInventory, nexusPlayer, nexusReady,
+ nexusReject, nexusReload, nexusTurnTwo, orderNexusSpice,
+} from './fixture-nexus-cards';
+
+const owner = 'h';
+const ecaz = 'f';
+const ally = 'a';
+const location = 'wind_pass';
+
+function setup(): { game: Game; action: Action } {
+ let g = nexusTurnTwo({ hostFaction: 'ecaz', secondFaction: 'emperor', advanced: true });
+ orderNexusSpice(g, ['worm', 'land', 'land']);
+ g = nexusReady(nexusReady(g));
+ assert.equal(g.nexus, true);
+ // A reciprocal alliance is negotiated in the actual second-turn Nexus.
+ g = applyAction(g, ecaz, { type: 'alliance', target: ally });
+ g = applyAction(g, ally, { type: 'alliance', target: ecaz });
+ assert.equal(nexusPlayer(g, ecaz).ally, ally);
+ assert.equal(nexusPlayer(g, ally).ally, ecaz);
+ g = finishNexusSpice(g);
+ assert.equal(g.nexusCards!.phase!.stage, 'drawing');
+ const cards = g.nexusCards!.cards!;
+ assert.ok(cards.deck.includes('ecaz'));
+ cards.deck = ['ecaz', ...cards.deck.filter(card => card !== 'ecaz')];
+ g = applyAction(g, owner, {
+ type: 'nexusCardChoice', turn: g.turn, card: null, choice: 'draw', ownRedraws: 0,
+ });
+ assert.equal(g.phase, 2);
+ assert.equal(g.nexusCards!.cards!.hands[owner], 'ecaz');
+ g = nexusReady(g);
+ for (let step = 0; g.phase === 3 && step < 30; step++) {
+ if (g.phaseOpening) {
+ const id = g.players.find(p => !g.phaseOpening!.passed.includes(p.id))!.id;
+ g = applyAction(g, id, { type: 'ready' });
+ } else if (g.auction) {
+ g = applyAction(g, g.auction.active, { type: 'passBid' });
+ } else {
+ g = nexusReady(g);
+ }
+ }
+ assert.equal(g.phase, 4, 'actual auction must reach revival');
+ for (let step = 0; g.phase === 4 && step < 10; step++) {
+ if (g.decision?.kind === 'ecazPlacement')
+ g = applyAction(g, g.decision.player, { type: 'decision', decline: true });
+ else if (g.response) g = nexusAllow(g);
+ else g = nexusReady(g);
+ }
+ assert.equal(g.phase, 5, 'actual revival must reach shipment opening');
+ assert.equal(g.active, g.order[0]);
+ assert.deepEqual(g.movementRemaining, g.order);
+
+ // Reallocate only existing physical counters from the genuine Emperor seat.
+ const target = nexusPlayer(g, ally);
+ target.reserves += Object.values(target.forces).reduce((sum, n) => sum + n, 0);
+ target.elites!.reserves += Object.values(target.elites!.forces).reduce((sum, n) => sum + n, 0);
+ target.forces = { 'wind_pass:14': 3, 'wind_pass:15': 2, 'carthag:11': 1 };
+ target.elites!.forces = { 'wind_pass:14': 1, 'wind_pass:15': 1 };
+ target.reserves -= 6;
+ target.elites!.reserves -= 2;
+ const native = nexusPlayer(g, ecaz);
+ native.reserves -= 2;
+ native.forces['wind_pass:14'] = (native.forces['wind_pass:14'] ?? 0) + 2;
+ nexusInventory(g);
+ const offer = viewGame(g, owner).nexusEcazBetrayal!;
+ assert.equal(offer.blocked, null);
+ assert.equal(offer.ally, ally);
+ assert.deepEqual(offer.territories.find(row => row.territory === location), {
+ territory: location,
+ sectors: {
+ 'wind_pass:14': { normal: 2, elite: 1 },
+ 'wind_pass:15': { normal: 1, elite: 1 },
+ },
+ total: { normal: 3, elite: 2 },
+ });
+ return { game: g, action: { type: 'nexusEcazBetrayal', event: offer.event, territory: location } };
+}
+
+function karama(g: Game): string {
+ const index = g.deck.findIndex(card => card.effect === 'karama');
+ assert.ok(index >= 0, 'a physical Karama remains in the treachery deck');
+ const card = g.deck.splice(index, 1)[0];
+ nexusPlayer(g, ecaz).hand.push(card);
+ nexusInventory(g);
+ return card.id;
+}
+
+void test('real Nexus card spends once and returns every typed sector without using shipment or movement', () => {
+ const { game, action } = setup();
+ karama(game); // Keep the ordinary response open until every player passes.
+ const before = nexusReload(game);
+ const target = nexusPlayer(before, ally);
+ const projected = viewGame(before, owner).nexusEcazBetrayal!;
+ for (const id of [ecaz, ally]) {
+ assert.equal(viewGame(before, id).nexusEcazBetrayal, null);
+ assert.equal(viewGame(before, id).nexusCards?.card, null);
+ }
+ assert.equal(projected.territories.length > 0, true);
+ nexusReject(game, ecaz, action, /Ecaz|Nexus/);
+ nexusReject(game, ally, action, /Ecaz|Nexus/);
+ nexusReject(game, owner, { ...action, event: 'stale' }, /Ecaz|Nexus/);
+ nexusReject(game, owner, { ...action, territory: 'polar_sink' }, /Ecaz|Nexus/);
+ assert.deepEqual(game, before);
+ let pending = applyAction(nexusReload(game), owner, action);
+ assert.equal(pending.response?.kind, 'nexusEcazBetrayal');
+ assert.equal(pending.nexusCards!.cards!.hands[owner], null);
+ assert.deepEqual(pending.nexusCards!.cards!.discard, ['ecaz']);
+ assert.equal(pending.nexusEcazBetrayalHistory?.[0].stage, 'pending');
+ assert.deepEqual(nexusPlayer(pending, ally).forces, target.forces);
+ nexusReject(pending, owner, action, /Ecaz|Nexus/);
+ pending = nexusReload(pending);
+ assert.deepEqual(normalizeAutomaticGame(nexusReload(pending)), pending);
+ for (const counter of ['reserves', 'eliteReserves'] as const) {
+ const corrupted = nexusReload(pending);
+ const affected = nexusPlayer(corrupted, ally);
+ if (counter === 'reserves') affected.reserves = '14' as never;
+ else affected.elites!.reserves = '3' as never;
+ const saved = nexusReload(corrupted);
+ assert.throws(() => nexusAllow(corrupted), /valid reserve and elite counters/);
+ assert.deepEqual(corrupted, saved);
+ }
+ const result = nexusAllow(pending);
+ const returned = nexusPlayer(result, ally);
+ assert.equal(returned.forces['wind_pass:14'], undefined);
+ assert.equal(returned.forces['wind_pass:15'], undefined);
+ assert.equal(returned.elites!.forces['wind_pass:14'], undefined);
+ assert.equal(returned.elites!.forces['wind_pass:15'], undefined);
+ assert.equal(returned.reserves, target.reserves + 5);
+ assert.equal(returned.elites!.reserves, target.elites!.reserves + 2);
+ assert.equal(returned.forces['carthag:11'], 1);
+ assert.deepEqual(nexusPlayer(result, ecaz).forces, nexusPlayer(before, ecaz).forces);
+ assert.equal(result.phase, 5);
+ assert.equal(result.active, before.active);
+ assert.deepEqual(result.movementRemaining, before.movementRemaining);
+ for (const p of result.players) {
+ assert.equal(p.shipped, false);
+ assert.equal(p.moved, 0);
+ }
+ assert.equal(result.nexusEcazBetrayalHistory?.[0].stage, 'complete');
+ assert.equal(result.nexusCards!.cards!.discard.filter(card => card === 'ecaz').length, 1);
+ nexusReject(result, owner, action, /Ecaz|Nexus/);
+ nexusInventory(result);
+});
+
+void test('Karama cancellation after saved JSON leaves both force groups in place but spends both physical cards', () => {
+ const { game, action } = setup();
+ const card = karama(game);
+ const original = nexusReload(game);
+ let pending = applyAction(nexusReload(game), owner, action);
+ pending = nexusReload(pending);
+ assert.equal(pending.nexusEcazBetrayalHistory?.[0].stage, 'pending');
+ let canceled = applyAction(pending, ecaz, { type: 'card', card, mode: 'cancel' });
+ canceled = nexusAllow(nexusReload(canceled));
+ assert.equal(canceled.response, null);
+ assert.equal(canceled.nexusEcazBetrayalHistory?.[0].stage, 'complete');
+ for (const id of [ally, ecaz]) {
+ assert.deepEqual(nexusPlayer(canceled, id).forces, nexusPlayer(original, id).forces);
+ assert.deepEqual(nexusPlayer(canceled, id).elites, nexusPlayer(original, id).elites);
+ assert.equal(nexusPlayer(canceled, id).reserves, nexusPlayer(original, id).reserves);
+ }
+ assert.equal(canceled.discard.filter(item => item.id === card).length, 1);
+ assert.equal(canceled.nexusCards!.cards!.discard.filter(item => item === 'ecaz').length, 1);
+ assert.equal(canceled.phase, 5);
+ assert.equal(canceled.active, original.active);
+ assert.deepEqual(canceled.movementRemaining, original.movementRemaining);
+ nexusReject(canceled, owner, action, /Ecaz|Nexus/);
+ nexusInventory(canceled);
+});
+
+void test('a bot holding the real card proposes a legal opening action from its private view', () => {
+ const { game } = setup();
+ karama(game); // The bot's declaration opens a real Karama response.
+ const view = viewGame(game, owner);
+ view.players.find(p => p.id === owner)!.bot = 'Easy';
+ const action = botActions(view).find(candidate => candidate.type === 'nexusEcazBetrayal');
+ assert.ok(action, 'bot should act on a legal Ecaz Betrayal offer');
+ assert.equal(action.territory, location);
+ const pending = applyAction(game, owner, action);
+ assert.equal(pending.response?.kind, 'nexusEcazBetrayal');
+ nexusInventory(nexusAllow(pending));
+});
+
+void test('an automated holder spends Betrayal before a seated-earlier opening movement bot', () => {
+ const { game } = setup();
+ game.order = [ecaz, ally, owner];
+ game.movementRemaining = [...game.order];
+ game.active = ecaz;
+ nexusPlayer(game, ecaz).bot = 'Easy';
+ nexusPlayer(game, owner).bot = 'Easy';
+ karama(game);
+ const next = runBots(game, 1);
+ assert.equal(next.response?.kind, 'nexusEcazBetrayal');
+ assert.equal(next.nexusEcazBetrayalHistory?.[0].stage, 'pending');
+ assert.equal(next.nexusCards!.cards!.hands[owner], null);
+ nexusInventory(next);
+});
diff --git a/tests/nexus-fremen-cunning-recovery.test.ts b/tests/nexus-fremen-cunning-recovery.test.ts
new file mode 100644
index 00000000..e0d939ff
--- /dev/null
+++ b/tests/nexus-fremen-cunning-recovery.test.ts
@@ -0,0 +1,170 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { type Action, type Game, viewGame } from '../game/engine';
+import { presenceAt } from '../game/force-presence';
+import type { RoomsClock } from '../db/rooms';
+import { nexusInventory, nexusReady, nexusTurnTwo, orderNexusSpice } from './fixture-nexus-cards';
+import { unitStore } from './fixture-nexus-room-store';
+
+const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
+
+async function fixture() {
+ const store = unitStore();
+ const made = await store.rooms.createRoom('Fremen Cunning SQL', 'fremen', true, []);
+ const code = made.view.code;
+ const tokens = [made.token];
+ for (const faction of ['atreides', 'harkonnen'] as const)
+ tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
+ const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token)));
+ const ids = auths.map(auth => auth.playerId) as [string, string, string];
+ let g = nexusTurnTwo({ seatIds: ids, advanced: true });
+ const cards = g.nexusCards!.cards!;
+ const index = cards.deck.indexOf('fremen');
+ assert.ok(index >= 0);
+ cards.hands[ids[0]] = cards.deck.splice(index, 1)[0];
+ const sector = g.storm === 14 ? 15 : 14;
+ g.players[0].reserves--;
+ g.players[0].forces[`wind_pass:${sector}`] = 1;
+ orderNexusSpice(g, ['worm', 'land']);
+ const empty = g.spiceDeck.find(card => 'territory' in card &&
+ g.players.every(player => presenceAt(player, card.territory) === 0));
+ assert.ok(empty && 'territory' in empty);
+ g.spiceDeck.splice(g.spiceDeck.indexOf(empty), 1);
+ if (!('territory' in g.spiceDeck[1])) {
+ const nextLand = g.spiceDeck.findIndex((card, i) => i > 1 && 'territory' in card);
+ assert.ok(nextLand > 1);
+ g.spiceDeck.splice(1, 0, g.spiceDeck.splice(nextLand, 1)[0]);
+ }
+ g.spiceDiscard[0].push(empty);
+ const karama = g.deck.findIndex(card => card.effect === 'karama');
+ assert.ok(karama >= 0);
+ g.players[1].hand.push(g.deck.splice(karama, 1)[0]);
+ g = nexusReady(g);
+ assert.equal(g.decision?.kind, 'nexusFremenCunningOffer');
+ assert.ok('territory' in g.spiceDeck[0], 'The original worm must draw a land card next.');
+ g.code = code;
+ g.version = (await store.rooms.readRoom(code)).version;
+ store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
+ .run(JSON.stringify(g), g.version, code);
+ store.writes.length = 0;
+ async function act(seat: number, action: Action) {
+ const current = await store.restart().readRoom(code);
+ await store.restart().act(code, auths[seat], current.version, action, clock);
+ return store.restart().readRoom(code);
+ }
+ async function assertPrivateSeats(state: Game) {
+ for (const [i, token] of tokens.entries()) {
+ const rooms = store.restart();
+ const auth = await rooms.authenticate(code, token);
+ const view = await rooms.readSeatView(code, auth);
+ assert.deepEqual(view, viewGame(state, ids[i]));
+ if (i !== 0) assert.equal(view.nexusCards?.card, null);
+ for (const rival of view.players.filter(player => player.id !== auth.playerId)) {
+ assert.equal(rival.hand, undefined);
+ assert.equal(rival.traitors, undefined);
+ }
+ }
+ }
+ async function settleResponseAndReachRide() {
+ for (let i = 0; i < 20; i++) {
+ const current = await store.restart().readRoom(code);
+ if (current.decision?.kind === 'nexusFremenCunningRide') return current;
+ if (current.response) {
+ const seat = ids.indexOf(current.players.find(player =>
+ !current.response!.passed.includes(player.id))!.id);
+ await act(seat, { type: 'passResponse' });
+ } else {
+ assert.equal(current.decision, null);
+ const seat = ids.indexOf(current.players.find(player =>
+ !current.ready.includes(player.id))!.id);
+ await act(seat, { type: 'ready' });
+ }
+ }
+ assert.fail('The saved accepted offer never reached its remote ride');
+ }
+ return { ...store, code, tokens, auths, ids, initial: g, sector, empty,
+ act, assertPrivateSeats, settleResponseAndReachRide };
+}
+
+void test('SQLite races one accepted Fremen offer, restores private Karama response and later rides exactly one force', async () => {
+ const f = await fixture();
+ try {
+ const event = f.initial.nexusFremenCunningOffer!.occurrence.event;
+ const originalReserves = f.initial.players[0].reserves;
+ const polarBefore = f.initial.players[0].forces['polar_sink:0'] ?? 0;
+ await f.assertPrivateSeats(f.initial);
+ assert.equal((await f.restart().readSeatView(f.code, f.auths[0])).nexusCards?.card, 'fremen');
+ let arrivals = 0;
+ let release!: () => void;
+ const gate = new Promise(resolve => { release = resolve; });
+ f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await gate; };
+ let results: PromiseSettledResult[];
+ try {
+ results = await Promise.allSettled([0, 1].map(() =>
+ f.restart().act(f.code, f.auths[0], f.initial.version,
+ { type: 'decision', event, accept: true }, clock)));
+ } finally { delete f.hooks.beforeWrite; }
+ assert.equal(arrivals, 2);
+ assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
+ assert.deepEqual(f.writes.map(write => write.changes).sort((a, b) => a - b), [0, 1]);
+ let g = await f.restart().readRoom(f.code);
+ assert.equal(g.version, f.initial.version + 1);
+ assert.equal(g.response?.kind, 'nexusFremenCunning');
+ assert.equal(g.nexusFremenCunningRides?.length, 1);
+ assert.equal(g.nexusCards!.cards!.hands[f.ids[0]], null);
+ assert.equal(g.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
+ assert.equal(g.players[0].forces[`wind_pass:${f.sector}`], 1);
+ nexusInventory(g);
+ await f.assertPrivateSeats(g);
+ await assert.rejects(f.restart().act(f.code, f.auths[0], f.initial.version,
+ { type: 'decision', event, accept: true }, clock), /table changed/i);
+ g = await f.settleResponseAndReachRide();
+ assert.equal(g.nexusFremenCunningRides?.[0].stage, 'select');
+ assert.ok(g.spiceDiscard[0].length > f.initial.spiceDiscard[0].length);
+ await f.assertPrivateSeats(g);
+ g = await f.act(0, {
+ type: 'decision', event, accept: true, source: 'wind_pass',
+ forces: { [`wind_pass:${f.sector}`]: { normal: 1, elite: 0 } },
+ territory: 'polar_sink', sector: 0,
+ });
+ assert.equal(g.nexusFremenCunningRides?.[0].stage, 'complete');
+ assert.equal(g.players[0].forces[`wind_pass:${f.sector}`] ?? 0, 0);
+ assert.equal(g.players[0].forces['polar_sink:0'], polarBefore + 1);
+ assert.equal(g.players[0].reserves, originalReserves);
+ assert.equal(g.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
+ nexusInventory(g);
+ await f.assertPrivateSeats(g);
+ const saved = JSON.stringify(g);
+ await f.restart().continueRoomAutomatic(f.code, clock);
+ assert.equal(JSON.stringify(await f.restart().readRoom(f.code)), saved);
+ } finally { f.sqlite.close(); }
+});
+
+void test('SQLite Karama cancellation of a spent remote ride preserves original worm and never moves the source', async () => {
+ const f = await fixture();
+ try {
+ const event = f.initial.nexusFremenCunningOffer!.occurrence.event;
+ await f.act(0, { type: 'decision', event, accept: true });
+ const response = await f.restart().readRoom(f.code);
+ assert.equal(response.response?.kind, 'nexusFremenCunning');
+ const card = response.players[1].hand.find(item => item.effect === 'karama');
+ assert.ok(card);
+ const g = await f.act(1, { type: 'card', card: card.id, mode: 'cancel' });
+ assert.equal(g.nexusFremenCunningRides?.[0].stage, 'complete');
+ assert.equal(g.players[0].forces[`wind_pass:${f.sector}`], 1);
+ assert.equal(g.players[0].forces[`${f.empty.territory}:${f.empty.sector}`] ?? 0, 0);
+ assert.equal(g.players[0].forces['polar_sink:0'] ?? 0,
+ f.initial.players[0].forces['polar_sink:0'] ?? 0);
+ assert.equal(g.nexusFremenCunningOffer, null);
+ assert.equal(g.spiceDiscard[0].length, f.initial.spiceDiscard[0].length + 1);
+ const nextSpice = f.initial.spiceDeck[0];
+ assert.ok('territory' in nextSpice);
+ assert.equal(g.spiceWindow?.territory, nextSpice.territory);
+ assert.equal(g.nexusCards!.cards!.discard.filter(item => item === 'fremen').length, 1);
+ nexusInventory(g);
+ await f.assertPrivateSeats(g);
+ const saved = JSON.stringify(g);
+ await f.restart().continueRoomAutomatic(f.code, clock);
+ assert.equal(JSON.stringify(await f.restart().readRoom(f.code)), saved);
+ } finally { f.sqlite.close(); }
+});
diff --git a/tests/nexus-fremen-cunning-summon-recovery.test.ts b/tests/nexus-fremen-cunning-summon-recovery.test.ts
new file mode 100644
index 00000000..4139dd28
--- /dev/null
+++ b/tests/nexus-fremen-cunning-summon-recovery.test.ts
@@ -0,0 +1,279 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { type Action, type Game, viewGame } from '../game/engine';
+import type { RoomsClock } from '../db/rooms';
+import { TERRITORIES } from '../game/board';
+import { presenceAt } from '../game/force-presence';
+import { nexusInventory, nexusReady, nexusTurnTwo, orderNexusSpice } from './fixture-nexus-cards';
+import { unitStore } from './fixture-nexus-room-store';
+
+const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
+
+async function fixture() {
+ const store = unitStore();
+ try {
+ const made = await store.rooms.createRoom('Summoned Fremen Cunning SQL', 'fremen', true, []);
+ const code = made.view.code;
+ const tokens = [made.token];
+ for (const faction of ['atreides', 'harkonnen'] as const)
+ tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
+ const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token)));
+ const ids = auths.map(auth => auth.playerId) as [string, string, string];
+ let g = nexusTurnTwo({ seatIds: ids, advanced: true });
+ assert.deepEqual(g.expansions, []);
+ assert.equal(g.turn, 2);
+ assert.equal(g.phase, 1);
+ const cards = g.nexusCards!.cards!;
+ const index = cards.deck.indexOf('fremen');
+ assert.ok(index >= 0);
+ cards.hands[ids[0]] = cards.deck.splice(index, 1)[0];
+ const sourceSector = g.storm === 14 ? 15 : 14;
+ const source = `wind_pass:${sourceSector}`;
+ const fremen = g.players[0];
+ assert.ok(fremen.elites && fremen.elites.reserves > 0);
+ fremen.reserves -= 3;
+ fremen.forces[source] = (fremen.forces[source] ?? 0) + 3;
+ fremen.elites.reserves--;
+ fremen.elites.forces[source] = (fremen.elites.forces[source] ?? 0) + 1;
+ orderNexusSpice(g, ['land', 'land']);
+ g = nexusReady(g); // A real first blow is in progress, not an invented Nexus window.
+ assert.ok(g.spiceWindow && g.spiceSequence);
+ const target = TERRITORIES.find(row => row.type === 'sand' &&
+ row.id !== g.spiceWindow!.territory && row.id !== 'wind_pass' &&
+ g.players.every(player => presenceAt(player, row.id) === 0));
+ assert.ok(target);
+ const karamaIndex = g.deck.findIndex(card => card.effect === 'karama');
+ assert.ok(karamaIndex >= 0);
+ const karama = g.deck.splice(karamaIndex, 1)[0];
+ g.players[0].hand.push(karama);
+ const cancelIndex = g.deck.findIndex(card => card.effect === 'karama');
+ assert.ok(cancelIndex >= 0);
+ const counterKarama = g.deck.splice(cancelIndex, 1)[0];
+ g.players[1].hand.push(counterKarama);
+ nexusInventory(g);
+ g.code = code;
+ g.version = (await store.rooms.readRoom(code)).version;
+ store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
+ .run(JSON.stringify(g), g.version, code);
+ store.writes.length = 0;
+ async function read() { return store.restart().readRoom(code); }
+ async function act(seat: number, action: Action) {
+ const current = await read();
+ await store.restart().act(code, auths[seat], current.version, action, clock);
+ return read();
+ }
+ async function privateSeats(state: Game) {
+ for (const [i, token] of tokens.entries()) {
+ const rooms = store.restart();
+ const auth = await rooms.authenticate(code, token);
+ const view = await rooms.readSeatView(code, auth);
+ assert.deepEqual(view, viewGame(state, ids[i]));
+ assert.equal(view.nexusCards?.card, i === 0 &&
+ state.nexusCards!.cards!.hands[ids[0]] === 'fremen' ? 'fremen' : null);
+ for (const rival of view.players.filter(player => player.id !== auth.playerId)) {
+ assert.equal(rival.hand, undefined);
+ assert.equal(rival.traitors, undefined);
+ }
+ }
+ }
+ async function passResponses() {
+ for (let i = 0; i < 12; i++) {
+ const current = await read();
+ if (!current.response) return current;
+ const owner = current.players.find(player => !current.response!.passed.includes(player.id));
+ assert.ok(owner);
+ await act(ids.indexOf(owner.id), { type: 'passResponse' });
+ }
+ assert.fail('Saved summon response did not settle');
+ }
+ async function readyEveryone() {
+ for (let i = 0; i < ids.length; i++) {
+ const state = await read();
+ const id = ids.find(seat => !state.ready.includes(seat));
+ if (!id) return state;
+ await act(ids.indexOf(id), { type: 'ready' });
+ }
+ return read();
+ }
+ const parent = await act(1, { type: 'ready' });
+ assert.deepEqual(parent.ready, [ids[1]]);
+ const summoned = await act(0, {
+ type: 'card', mode: 'special', card: karama.id, territory: target.id,
+ });
+ return { ...store, code, ids, auths, source, target: target.id, karama,
+ counterKarama, parent, summoned, read, act, privateSeats, passResponses, readyEveryone };
+ } catch (error) {
+ store.sqlite.close();
+ throw error;
+ }
+}
+
+function spiceInventory(state: Game) {
+ return [...state.spiceDeck, ...state.spiceDiscard.flat()].map(card => JSON.stringify(card)).sort();
+}
+function eliteInventory(state: Game) {
+ return state.players.map(player => player.elites && (
+ player.elites.reserves + player.elites.tanks +
+ Object.values(player.elites.forces).reduce((sum, count) => sum + count, 0)
+ ));
+}
+
+void test('audited summoned empty worm: SQL CAS spends one Nexus card, restarts response and resumes the interrupted blow through a typed remote ride', async () => {
+ const f = await fixture();
+ try {
+ const before = f.summoned;
+ assert.equal(before.version, f.parent.version + 1);
+ assert.equal(before.players[0].specialKaramaUsed, true);
+ assert.equal(before.decision?.kind, 'nexusFremenCunningOffer');
+ assert.equal(before.summonedWorm?.territory, f.target);
+ assert.ok(before.summonedWorm?.event);
+ const occurrence = before.nexusFremenCunningOffer!.occurrence;
+ assert.equal(occurrence.origin, 'summoned');
+ assert.equal(occurrence.parent, before.summonedWorm.event);
+ assert.notEqual(occurrence.event, occurrence.parent);
+ assert.equal(occurrence.territory, f.target);
+ assert.equal(occurrence.turn, 2);
+ assert.equal(occurrence.initiallyEmpty, true);
+ assert.deepEqual(before.summonedWorm.resume.spiceWindow, f.parent.spiceWindow);
+ assert.deepEqual(before.summonedWorm.resume.spiceSequence, f.parent.spiceSequence);
+ assert.deepEqual(before.summonedWorm.resume.spiceResolution, f.parent.spiceResolution);
+ assert.deepEqual(before.summonedWorm.resume.ready, f.parent.ready);
+ assert.equal(before.discard.filter(card => card.id === f.karama.id).length, 1);
+ assert.equal(before.players[0].hand.some(card => card.id === f.karama.id), false);
+ assert.deepEqual(spiceInventory(before), spiceInventory(f.parent));
+ assert.deepEqual(eliteInventory(before), eliteInventory(f.parent));
+ await f.privateSeats(before);
+ for (const seat of [1, 2]) {
+ const view = await f.restart().readSeatView(f.code, f.auths[seat]);
+ assert.equal(view.nexusCards?.card, null);
+ assert.equal(view.summonedWorm?.territory, f.target);
+ assert.equal('resume' in view.summonedWorm!, false);
+ assert.equal('event' in view.summonedWorm!, false);
+ }
+ f.writes.length = 0;
+ let arrivals = 0;
+ let release!: () => void;
+ const gate = new Promise(resolve => { release = resolve; });
+ f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await gate; };
+ let results: PromiseSettledResult[];
+ try {
+ results = await Promise.allSettled([0, 1].map(() =>
+ f.restart().act(f.code, f.auths[0], before.version,
+ { type: 'decision', event: occurrence.event, accept: true }, clock)
+ .catch(error => { release(); throw error; })));
+ } finally { delete f.hooks.beforeWrite; }
+ assert.equal(arrivals, 2);
+ assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
+ assert.deepEqual(f.writes.map(write => write.changes).sort((a, b) => a - b), [0, 1]);
+ let g = await f.read();
+ assert.equal(g.version, before.version + 1);
+ assert.equal(g.response?.kind, 'nexusFremenCunning');
+ assert.equal(g.response?.intent, occurrence.event);
+ assert.equal(g.nexusFremenCunningRides?.length, 1);
+ assert.equal(g.nexusFremenCunningRides![0].stage, 'pending');
+ assert.deepEqual(g.nexusFremenCunningRides![0].occurrence, occurrence);
+ assert.equal(g.nexusCards!.cards!.hands[f.ids[0]], null);
+ assert.equal(g.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
+ assert.equal(g.players[0].forces[f.source], before.players[0].forces[f.source]);
+ assert.equal(g.players[0].elites!.forces[f.source], before.players[0].elites!.forces[f.source]);
+ assert.deepEqual(eliteInventory(g), eliteInventory(f.parent));
+ nexusInventory(g);
+ await f.privateSeats(g);
+ const spent = JSON.stringify(g);
+ await f.restart().continueRoomAutomatic(f.code, clock);
+ assert.equal(JSON.stringify(await f.read()), spent);
+ await assert.rejects(f.restart().act(f.code, f.auths[0], before.version,
+ { type: 'decision', event: occurrence.event, accept: true }, clock), /table changed/i);
+ assert.equal(JSON.stringify(await f.read()), spent);
+
+ g = await f.passResponses();
+ assert.equal(g.summonedWorm, null);
+ assert.equal(g.nexus, true);
+ assert.equal(g.nexusFremenCunningRides?.[0].stage, 'queued');
+ const targetName = TERRITORIES.find(row => row.id === f.target)!.name;
+ assert.equal(g.log.filter(line => line.text === `Shai-Hulud appeared in ${targetName}.`).length, 1);
+ assert.deepEqual(g.spiceWindow, f.parent.spiceWindow);
+ assert.deepEqual(g.spiceSequence, f.parent.spiceSequence);
+ assert.deepEqual(g.spiceDeck, f.parent.spiceDeck);
+ assert.deepEqual(g.spiceDiscard, f.parent.spiceDiscard);
+ assert.deepEqual(spiceInventory(g), spiceInventory(f.parent));
+ assert.deepEqual(g.wormRides, f.parent.wormRides);
+ await f.privateSeats(g);
+ g = await f.readyEveryone(); // Close the original blow, opening the summoned Nexus.
+ assert.equal(g.spiceWindow, null);
+ assert.equal(g.nexus, true);
+ g = await f.readyEveryone(); // Close that Nexus before the Cunning remote ride.
+ assert.equal(g.decision?.kind, 'nexusFremenCunningRide');
+ assert.equal(g.nexusFremenCunningRides?.[0].stage, 'select');
+ assert.equal(g.spiceDeck.length, f.parent.spiceDeck.length);
+ await f.privateSeats(g);
+ const reserves = g.players[0].reserves;
+ const eliteReserves = g.players[0].elites!.reserves;
+ const polar = g.players[0].forces['polar_sink:0'] ?? 0;
+ const polarElite = g.players[0].elites!.forces['polar_sink:0'] ?? 0;
+ g = await f.act(0, {
+ type: 'decision', event: occurrence.event, accept: true, source: 'wind_pass',
+ forces: { [f.source]: { normal: 1, elite: 1 } },
+ territory: 'polar_sink', sector: 0,
+ });
+ assert.equal(g.nexusFremenCunningRides?.[0].stage, 'complete');
+ assert.equal(g.players[0].forces[f.source], before.players[0].forces[f.source] - 2);
+ assert.equal(g.players[0].elites!.forces[f.source] ?? 0, (before.players[0].elites!.forces[f.source] ?? 0) - 1);
+ assert.equal(g.players[0].forces['polar_sink:0'], polar + 2);
+ assert.equal(g.players[0].elites!.forces['polar_sink:0'], polarElite + 1);
+ assert.equal(g.players[0].reserves, reserves);
+ assert.equal(g.players[0].elites!.reserves, eliteReserves);
+ assert.equal(g.discard.filter(card => card.id === f.karama.id).length, 1);
+ assert.equal(g.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
+ assert.deepEqual(spiceInventory(g), spiceInventory(f.parent));
+ assert.deepEqual(eliteInventory(g), eliteInventory(f.parent));
+ const nextBlow = f.parent.spiceDeck[0];
+ assert.ok('territory' in nextBlow);
+ assert.equal(g.spiceWindow?.territory, nextBlow.territory);
+ nexusInventory(g);
+ await f.privateSeats(g);
+ const saved = JSON.stringify(g);
+ await f.restart().continueRoomAutomatic(f.code, clock);
+ assert.equal(JSON.stringify(await f.read()), saved);
+ } finally { f.sqlite.close(); }
+});
+
+void test('canceled summoned Cunning still resolves the physical worm and resumes the saved original blow', async () => {
+ const f = await fixture();
+ try {
+ const occurrence = f.summoned.nexusFremenCunningOffer!.occurrence;
+ await f.act(0, { type: 'decision', event: occurrence.event, accept: true });
+ const pending = await f.read();
+ assert.equal(pending.response?.kind, 'nexusFremenCunning');
+ assert.equal(pending.version, f.summoned.version + 1);
+ let g = await f.act(1, { type: 'card', card: f.counterKarama.id, mode: 'cancel' });
+ assert.equal(g.version, pending.version + 1);
+ assert.equal(g.response, null);
+ assert.equal(g.summonedWorm, null);
+ assert.equal(g.nexusFremenCunningRides?.[0].stage, 'complete');
+ assert.equal(g.players[0].forces[f.source], f.parent.players[0].forces[f.source]);
+ assert.equal(g.players[0].elites!.forces[f.source], f.parent.players[0].elites!.forces[f.source]);
+ assert.deepEqual(g.spiceWindow, f.parent.spiceWindow);
+ assert.deepEqual(g.spiceSequence, f.parent.spiceSequence);
+ assert.deepEqual(spiceInventory(g), spiceInventory(f.parent));
+ assert.deepEqual(eliteInventory(g), eliteInventory(f.parent));
+ assert.equal(g.discard.filter(card => card.id === f.karama.id).length, 1);
+ assert.equal(g.discard.filter(card => card.id === f.counterKarama.id).length, 1);
+ assert.equal(g.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
+ nexusInventory(g);
+ await f.privateSeats(g);
+ g = await f.readyEveryone();
+ assert.equal(g.nexus, true);
+ g = await f.readyEveryone();
+ assert.notEqual(g.decision?.kind, 'nexusFremenCunningRide');
+ assert.equal(g.nexusFremenCunningRides?.[0].stage, 'complete');
+ assert.equal(g.players[0].forces[f.source], f.parent.players[0].forces[f.source]);
+ assert.deepEqual(spiceInventory(g), spiceInventory(f.parent));
+ assert.equal(g.log.filter(line => line.text ===
+ `Shai-Hulud appeared in ${TERRITORIES.find(row => row.id === f.target)!.name}.`).length, 1);
+ await f.privateSeats(g);
+ const saved = JSON.stringify(g);
+ await f.restart().continueRoomAutomatic(f.code, clock);
+ assert.equal(JSON.stringify(await f.read()), saved);
+ } finally { f.sqlite.close(); }
+});
diff --git a/tests/nexus-fremen-cunning-summon.test.ts b/tests/nexus-fremen-cunning-summon.test.ts
new file mode 100644
index 00000000..7484e6c4
--- /dev/null
+++ b/tests/nexus-fremen-cunning-summon.test.ts
@@ -0,0 +1,388 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import { TERRITORIES } from '../game/board';
+import { applyAction, viewGame, type Game } from '../game/engine';
+import { presenceAt } from '../game/force-presence';
+import {
+ nexusAllow, nexusInventory, nexusReady, nexusReload, nexusTurnTwo,
+ orderNexusSpice,
+} from './fixture-nexus-cards';
+
+const source = 'wind_pass';
+const destination = 'polar_sink:0';
+
+function fixture(ordinaryRide = false) {
+ // This is the audited three-seat game after a completed first turn, not a
+ // synthetic spice pile or duplicate Nexus/Karama card.
+ let g = nexusTurnTwo({ advanced: true });
+ const cards = g.nexusCards!.cards!;
+ const nexusIndex = cards.deck.indexOf('fremen');
+ assert.ok(nexusIndex >= 0);
+ cards.hands.f = cards.deck.splice(nexusIndex, 1)[0];
+ const karamaIndex = g.deck.findIndex(card => card.effect === 'karama');
+ assert.ok(karamaIndex >= 0);
+ g.players[0].hand.push(g.deck.splice(karamaIndex, 1)[0]);
+ const counterKarama = g.deck.findIndex(card => card.effect === 'karama');
+ assert.ok(counterKarama >= 0);
+ g.players[1].hand.push(g.deck.splice(counterKarama, 1)[0]);
+ const previous = g.spiceDiscard[0].findLast(card => 'territory' in card);
+ assert.ok(!ordinaryRide || (previous && 'territory' in previous));
+ const rideTerritory = ordinaryRide && previous && 'territory' in previous
+ ? previous.territory : null;
+ // The prior Spice Blow can be rock. Its worm still grants a native ride,
+ // but the separate Cunning ride needs a physically occupied desert source.
+ const sourceTerritory = rideTerritory &&
+ TERRITORIES.find(row => row.id === rideTerritory)?.type === 'sand'
+ ? rideTerritory : source;
+ const sourceSector = sourceTerritory === source
+ ? g.storm === 14 ? 15 : 14
+ : TERRITORIES.find(row => row.id === sourceTerritory)!.sectors.find(sector => sector !== g.storm)!;
+ const sourceKey = `${sourceTerritory}:${sourceSector}`;
+ const rideSector = rideTerritory && rideTerritory !== sourceTerritory
+ ? TERRITORIES.find(row => row.id === rideTerritory)!.sectors.find(sector => sector !== g.storm)!
+ : null;
+ const fremen = g.players[0];
+ assert.ok(fremen.reserves >= (rideSector === null ? 2 : 3));
+ fremen.reserves -= rideSector === null ? 2 : 3;
+ fremen.forces[sourceKey] = (fremen.forces[sourceKey] ?? 0) + 2;
+ if (rideSector !== null) {
+ const rideKey = `${rideTerritory}:${rideSector}`;
+ fremen.forces[rideKey] = (fremen.forces[rideKey] ?? 0) + 1;
+ }
+ if (ordinaryRide) {
+ // The preceding turn left a physical land card in pile zero. A real worm
+ // drawn next attacks that land; Fremen there earn an ordinary ride.
+ const wormIndex = g.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker);
+ assert.ok(wormIndex >= 0);
+ g.spiceDeck.unshift(g.spiceDeck.splice(wormIndex, 1)[0]);
+ const landIndex = g.spiceDeck.findIndex((card, index) => index > 0 && 'territory' in card);
+ assert.ok(landIndex > 0);
+ g.spiceDeck.splice(1, 0, g.spiceDeck.splice(landIndex, 1)[0]);
+ } else orderNexusSpice(g, ['land', 'land']);
+ g = nexusReady(g);
+ if (ordinaryRide) {
+ g = nexusAllow(g);
+ assert.deepEqual(g.wormRides, [rideTerritory]);
+ }
+ assert.ok(g.spiceWindow && g.spiceSequence);
+ const target = TERRITORIES.find(row => row.type === 'sand' &&
+ row.id !== sourceTerritory && row.id !== g.spiceWindow!.territory &&
+ g.players.every(player => presenceAt(player, row.id) === 0));
+ assert.ok(target, 'The audited setup must leave an empty sand territory.');
+ assert.equal(g.phase, 1);
+ return { g, target: target.id, sourceKey, sourceTerritory, rideTerritory };
+}
+
+function summon(g: Game, target: string): Game {
+ return applyAction(g, 'f', {
+ type: 'card', mode: 'special', card: g.players[0].hand.find(card => card.effect === 'karama')!.id,
+ territory: target,
+ });
+}
+
+function accepted(g: Game) {
+ assert.equal(g.decision?.kind, 'nexusFremenCunningOffer');
+ const pending = g.nexusFremenCunningOffer!;
+ const event = pending.occurrence.event;
+ assert.equal(pending.owner, 'f');
+ assert.equal(pending.occurrence.origin, 'summoned');
+ assert.equal(pending.occurrence.turn, 2);
+ assert.equal(pending.occurrence.initiallyEmpty, true);
+ const parent = g.summonedWorm?.event;
+ assert.ok(parent);
+ assert.match(event, /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i);
+ assert.match(parent, /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i);
+ assert.equal(pending.occurrence.parent, parent);
+ assert.notEqual(event, parent);
+ assert.equal(viewGame(g, 'a').nexusCards?.card, null);
+ assert.equal(viewGame(g, 'f').nexusCards?.card, 'fremen');
+ const before = structuredClone(g);
+ assert.throws(() => applyAction(g, 'a', { type: 'decision', event, accept: true }));
+ assert.deepEqual(g, before, 'Other seats cannot spend Fremen Cunning.');
+ assert.throws(() => applyAction(g, 'f', {
+ type: 'decision', event: 'stale-summon-occurrence', accept: true,
+ }), /empty-worm Nexus opportunity/);
+ assert.deepEqual(g, before, 'A stale event cannot change the summon or its card.');
+ g = applyAction(nexusReload(g), 'f', { type: 'decision', event, accept: true });
+ assert.equal(g.response?.kind, 'nexusFremenCunning');
+ assert.equal(g.nexusCards!.cards!.hands.f, null);
+ assert.equal(g.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
+ assert.equal(g.nexusFremenCunningRides?.length, 1);
+ assert.equal(g.nexusFremenCunningRides?.[0].stage, 'pending');
+ assert.equal(g.nexusFremenCunningRides?.[0].occurrence.event, event);
+ return { g, event };
+}
+
+void test('special Karama accepts one real Fremen card, survives a response reload, and restores the Spice Blow and ordinary ride before its remote ride', () => {
+ const { g: original, target, sourceKey, sourceTerritory, rideTerritory } = fixture(true);
+ const parentSequence = structuredClone(original.spiceSequence);
+ const parentWindow = structuredClone(original.spiceWindow);
+ const originalSource = original.players[0].forces[sourceKey] ?? 0;
+ let g = summon(original, target);
+ assert.equal(g.summonedWorm?.territory, target);
+ assert.equal(g.nexusFremenCunningOffer?.occurrence.territory, target);
+ ({ g } = accepted(nexusReload(g)));
+ g = nexusAllow(nexusReload(g));
+ assert.equal(g.summonedWorm, null);
+ assert.equal(g.nexusFremenCunningOffer, null);
+ assert.equal(g.nexusFremenCunningRides?.[0].stage, 'queued');
+ assert.deepEqual(g.spiceSequence, parentSequence);
+ assert.deepEqual(g.spiceWindow, parentWindow);
+ assert.deepEqual(g.wormRides, [rideTerritory]);
+ assert.equal(g.nexus, true);
+ assert.equal(g.players[0].forces[sourceKey], originalSource);
+ assert.equal(g.discard.filter(card => card.effect === 'karama').length, 1);
+ g = nexusReady(g); // Finish the interrupted blow; then close the summoned Nexus.
+ assert.equal(g.nexus, true);
+ g = nexusReady(g);
+ assert.equal(g.decision?.kind, 'wormRide');
+ assert.equal(g.decision?.kind === 'wormRide' && g.decision.territory, rideTerritory);
+ g = applyAction(g, 'f', { type: 'decision', accept: false });
+ assert.equal(g.decision?.kind, 'nexusFremenCunningRide');
+ const event = g.nexusFremenCunningRides![0].occurrence.event;
+ assert.equal(g.decision?.kind === 'nexusFremenCunningRide' && g.decision.event, event);
+ const before = nexusReload(g);
+ assert.throws(() => applyAction(g, 'a', { type: 'decision', event, accept: false }));
+ assert.throws(() => applyAction(g, 'f', {
+ type: 'decision', event: 'stale-summon-occurrence', accept: false,
+ }), /pending Fremen Cunning remote worm ride/);
+ assert.deepEqual(g, before);
+ g = applyAction(nexusReload(g), 'f', {
+ type: 'decision', event, accept: true, source: sourceTerritory,
+ forces: { [sourceKey]: { normal: 1, elite: 0 } },
+ territory: 'polar_sink', sector: 0,
+ });
+ assert.equal(g.players[0].forces[sourceKey], originalSource - 1);
+ assert.equal(g.players[0].forces[destination],
+ (original.players[0].forces[destination] ?? 0) + 1);
+ assert.equal(g.nexusFremenCunningRides?.[0].stage, 'complete');
+ assert.equal(g.nexusFremenCunningRides?.length, 1);
+ nexusInventory(g);
+});
+
+void test('Karama cancellation stops only the accepted remote ride; declining leaves the card in hand', () => {
+ const { g: before, target, sourceKey } = fixture();
+ const offered = summon(before, target);
+ const event = offered.nexusFremenCunningOffer!.occurrence.event;
+ const declined = applyAction(nexusReload(offered), 'f', {
+ type: 'decision', event, accept: false,
+ });
+ assert.equal(declined.nexusCards!.cards!.hands.f, 'fremen');
+ assert.equal(declined.nexusFremenCunningRides?.length ?? 0, 0);
+ assert.equal(declined.summonedWorm, null);
+ assert.ok(declined.spiceWindow);
+ const { g: acceptedGame } = accepted(offered);
+ const canceled = applyAction(nexusReload(acceptedGame), 'a', {
+ type: 'card', mode: 'cancel', card: acceptedGame.players[1].hand[0].id,
+ });
+ assert.equal(canceled.response, null);
+ assert.equal(canceled.nexusFremenCunningRides?.[0].stage, 'complete');
+ assert.equal(canceled.nexusCards!.cards!.hands.f, null);
+ assert.equal(canceled.players[0].forces[sourceKey], before.players[0].forces[sourceKey]);
+ assert.equal(canceled.summonedWorm, null);
+ assert.deepEqual(canceled.spiceSequence, before.spiceSequence);
+ assert.deepEqual(canceled.spiceWindow, before.spiceWindow);
+ assert.ok(canceled.log.some(line => line.text.includes('Shai-Hulud appeared')));
+ nexusInventory(canceled);
+});
+
+void test('occupied appearance, absent desert source, or alliance has no Cunning offer; the summon still resolves', () => {
+ for (const unavailable of ['occupied', 'source', 'allied'] as const) {
+ const { g, target, sourceKey } = fixture();
+ if (unavailable === 'occupied') {
+ const other = g.players[1];
+ const sector = TERRITORIES.find(row => row.id === target)!.sectors.find(s => s !== g.storm)!;
+ other.reserves--;
+ other.forces[`${target}:${sector}`] = (other.forces[`${target}:${sector}`] ?? 0) + 1;
+ } else if (unavailable === 'source') {
+ g.players[0].reserves += g.players[0].forces[sourceKey];
+ delete g.players[0].forces[sourceKey];
+ for (const [key, count] of Object.entries(g.players[0].forces)) {
+ g.players[0].reserves += count;
+ delete g.players[0].forces[key];
+ }
+ } else {
+ g.players[0].ally = 'a';
+ g.players[1].ally = 'f';
+ g.nexusCards!.cards!.hands.f = null;
+ g.nexusCards!.cards!.deck.push('fremen');
+ }
+ const summoned = summon(g, target);
+ assert.notEqual(summoned.decision?.kind, 'nexusFremenCunningOffer', unavailable);
+ assert.equal(summoned.nexusFremenCunningOffer ?? null, null);
+ assert.equal(summoned.summonedWorm, null);
+ assert.deepEqual(summoned.spiceSequence, g.spiceSequence);
+ assert.ok(summoned.log.some(line => line.text.includes('Shai-Hulud appeared')));
+ nexusInventory(summoned);
+ }
+});
+
+void test('card-blind empty appearance cannot spend a Fremen card it does not physically hold', () => {
+ const { g, target } = fixture();
+ g.nexusCards!.cards!.hands.f = null;
+ g.nexusCards!.cards!.deck.push('fremen');
+ const offered = summon(g, target);
+ assert.equal(offered.decision?.kind, 'nexusFremenCunningOffer');
+ const event = offered.nexusFremenCunningOffer!.occurrence.event;
+ const before = structuredClone(offered);
+ assert.throws(() => applyAction(offered, 'f', {
+ type: 'decision', event, accept: true,
+ }), /Fremen Cunning needs/);
+ assert.deepEqual(offered, before);
+ const passed = applyAction(offered, 'f', { type: 'decision', event, accept: false });
+ assert.equal(passed.nexusFremenCunningOffer, null);
+ assert.equal(passed.nexusCards!.cards!.hands.f, null);
+ assert.equal(passed.summonedWorm, null);
+ nexusInventory(passed);
+});
+
+void test('restored summon offer and spent response reject a replaced parent before a card or force change', () => {
+ const { g: parent, target } = fixture();
+ const offered = summon(parent, target);
+ const event = offered.nexusFremenCunningOffer!.occurrence.event;
+ const wrongOffer = nexusReload(offered);
+ wrongOffer.summonedWorm!.event = 'different-special-karama';
+ const savedOffer = structuredClone(wrongOffer);
+ assert.throws(() => applyAction(wrongOffer, 'f', {
+ type: 'decision', event, accept: true,
+ }), /original special Karama parent/);
+ assert.deepEqual(wrongOffer, savedOffer);
+ const pending = applyAction(offered, 'f', { type: 'decision', event, accept: true });
+ assert.equal(pending.response?.kind, 'nexusFremenCunning');
+ const wrongResponse = nexusReload(pending);
+ wrongResponse.summonedWorm!.event = 'different-special-karama';
+ const savedResponse = structuredClone(wrongResponse);
+ assert.throws(() => applyAction(wrongResponse, 'a', { type: 'passResponse' }),
+ /summoned worm parent/);
+ assert.deepEqual(wrongResponse, savedResponse);
+ assert.equal(pending.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
+ nexusInventory(pending);
+});
+
+function naturalOfferWithUnusedSummon(secondFaction: 'atreides' | 'beneGesserit' = 'atreides') {
+ let g = nexusTurnTwo({ advanced: true, secondFaction });
+ const cards = g.nexusCards!.cards!;
+ const cardIndex = cards.deck.indexOf('fremen');
+ assert.ok(cardIndex >= 0);
+ cards.hands.f = cards.deck.splice(cardIndex, 1)[0];
+ for (const seat of [0, 1]) {
+ const index = g.deck.findIndex(card => card.effect === 'karama');
+ assert.ok(index >= 0);
+ g.players[seat].hand.push(g.deck.splice(index, 1)[0]);
+ }
+ const sourceSector = g.storm === 14 ? 15 : 14;
+ g.players[0].reserves -= 2;
+ g.players[0].forces[`wind_pass:${sourceSector}`] = 2;
+ orderNexusSpice(g, ['worm', 'land']);
+ const empty = g.spiceDeck.find((card, index) => index > 1 && 'territory' in card &&
+ card.territory !== 'wind_pass' &&
+ g.players.every(player => presenceAt(player, card.territory) === 0));
+ assert.ok(empty && 'territory' in empty);
+ g.spiceDeck.splice(g.spiceDeck.indexOf(empty), 1);
+ g.spiceDiscard[0].push(empty);
+ g = nexusReady(g);
+ assert.equal(g.decision?.kind, 'nexusFremenCunningOffer');
+ nexusInventory(g);
+ return g;
+}
+
+void test('a real special summon suspends and restores an earlier natural Cunning offer across worm survival', () => {
+ const original = naturalOfferWithUnusedSummon();
+ const event = original.nexusFremenCunningOffer!.occurrence.event;
+ const suspended = summon(original, 'wind_pass');
+ assert.equal(suspended.response?.kind, 'wormSurvival');
+ assert.equal(suspended.summonedWorm?.resume.decision?.kind, 'nexusFremenCunningOffer');
+ assert.equal(suspended.nexusFremenCunningOffer?.occurrence.event, event);
+ let g = nexusAllow(nexusReload(suspended));
+ assert.equal(g.summonedWorm, null);
+ assert.equal(g.decision?.kind, 'nexusFremenCunningOffer');
+ assert.equal(g.nexusFremenCunningOffer?.occurrence.event, event);
+ g = applyAction(g, 'f', { type: 'decision', event, accept: true });
+ assert.equal(g.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
+ nexusInventory(g);
+});
+
+void test('a summon during a selected remote ride reopens its Nexus before resuming that ride', () => {
+ let g = naturalOfferWithUnusedSummon();
+ const event = g.nexusFremenCunningOffer!.occurrence.event;
+ g = applyAction(g, 'f', { type: 'decision', event, accept: true });
+ g = nexusAllow(g);
+ for (let i = 0; g.phase === 1 && !g.decision && i < 6; i++)
+ g = g.response ? nexusAllow(g) : nexusReady(g);
+ assert.equal(g.decision?.kind, 'nexusFremenCunningRide');
+ const target = TERRITORIES.find(row => row.type === 'sand' && row.id !== 'wind_pass' &&
+ g.players.every(player => presenceAt(player, row.id) === 0));
+ assert.ok(target);
+ g = summon(g, target.id);
+ assert.equal(g.summonedWorm, null);
+ assert.equal(g.nexus, true);
+ assert.equal(g.decision, null, 'The new Nexus must precede the interrupted ride.');
+ assert.equal(g.nexusFremenCunningRides?.[0].stage, 'queued');
+ g = nexusReady(g);
+ assert.equal(g.decision?.kind, 'nexusFremenCunningRide');
+ assert.equal(g.nexusFremenCunningRides?.[0].occurrence.event, event);
+ g = applyAction(g, 'f', { type: 'decision', event, accept: false });
+ assert.equal(g.nexusFremenCunningRides?.[0].stage, 'complete');
+ nexusInventory(g);
+});
+
+void test('a summoned survival response preserves the earlier pending Cunning Karama response', () => {
+ let g = naturalOfferWithUnusedSummon();
+ const event = g.nexusFremenCunningOffer!.occurrence.event;
+ g = applyAction(g, 'f', { type: 'decision', event, accept: true });
+ assert.equal(g.response?.kind, 'nexusFremenCunning');
+ g = summon(g, 'wind_pass');
+ assert.equal(g.response?.kind, 'wormSurvival');
+ assert.equal(g.summonedWorm?.resume.response?.kind, 'nexusFremenCunning');
+ g = nexusReload(g);
+ for (let i = 0; g.summonedWorm && i < 6; i++) {
+ assert.equal(g.response?.kind, 'wormSurvival');
+ const id = g.players.find(player => !g.response!.passed.includes(player.id))!.id;
+ g = applyAction(g, id, { type: 'passResponse' });
+ }
+ assert.equal(g.summonedWorm, null);
+ assert.equal(g.response?.kind, 'nexusFremenCunning');
+ assert.equal(g.response.intent, event);
+ g = nexusAllow(g);
+ assert.equal(g.nexusFremenCunningRides?.[0].stage, 'queued');
+ nexusInventory(g);
+});
+
+void test('nested BG Karama conversions preserve the suspended Cunning cancellation', () => {
+ let g = naturalOfferWithUnusedSummon('beneGesserit');
+ const event = g.nexusFremenCunningOffer!.occurrence.event;
+ for (let i = 0; i < 2; i++) {
+ const index = g.deck.findIndex(card => card.kind === 'worthless');
+ assert.ok(index >= 0);
+ g.players[1].hand.push(g.deck.splice(index, 1)[0]);
+ }
+ const counterKarama = g.players[1].hand.findIndex(card => card.effect === 'karama');
+ assert.ok(counterKarama >= 0);
+ g.players[2].hand.push(g.players[1].hand.splice(counterKarama, 1)[0]);
+ g = applyAction(g, 'f', { type: 'decision', event, accept: true });
+ assert.equal(g.response?.kind, 'nexusFremenCunning');
+ g = applyAction(g, 'a', {
+ type: 'card', mode: 'cancel', card: g.players[1].hand.find(card => card.kind === 'worthless')!.id,
+ });
+ assert.equal(g.response?.kind, 'worthlessKarama');
+ assert.equal(g.pendingKarama?.use.kind, 'cancel');
+ g = summon(g, 'wind_pass');
+ assert.equal(g.response?.kind, 'wormSurvival');
+ assert.equal(g.summonedWorm?.resume.pendingKarama?.use.kind, 'cancel');
+ g = applyAction(g, 'a', {
+ type: 'card', mode: 'cancel', card: g.players[1].hand.find(card => card.kind === 'worthless')!.id,
+ });
+ assert.equal(g.response?.kind, 'worthlessKarama');
+ assert.equal(g.pendingKarama?.use.kind, 'cancel');
+ assert.equal(g.summonedWorm?.resume.pendingKarama?.use.kind, 'cancel');
+ g = applyAction(nexusReload(g), 'h', { type: 'passResponse' });
+ assert.equal(g.summonedWorm, null);
+ assert.equal(g.response?.kind, 'worthlessKarama');
+ assert.equal(g.pendingKarama?.use.kind, 'cancel');
+ if (g.pendingKarama?.use.kind === 'cancel')
+ assert.equal(g.pendingKarama.use.response.kind === 'nexusFremenCunning' &&
+ g.pendingKarama.use.response.intent, event);
+ g = nexusAllow(g);
+ assert.equal(g.nexusFremenCunningRides?.[0].stage, 'complete');
+ nexusInventory(g);
+});
diff --git a/tests/nexus-fremen-cunning.test.ts b/tests/nexus-fremen-cunning.test.ts
new file mode 100644
index 00000000..50bf1757
--- /dev/null
+++ b/tests/nexus-fremen-cunning.test.ts
@@ -0,0 +1,276 @@
+import assert from 'node:assert/strict';
+import { TERRITORIES } from '../game/board';
+import { presenceAt } from '../game/force-presence';
+import { test } from 'node:test';
+import { captureFremenCunningOccurrence, fremenCunningDestinationBlock,
+ quoteFremenCunning, quoteFremenCunningRide, validateFremenCunningSelection,
+ type FremenCunningOccurrence, type FremenCunningRideAuthorization,
+ type FremenCunningSelection } from '../game/nexus-fremen-cunning';
+import { applyAction, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
+import { botActions } from '../game/bots';
+import { nexusFixture, nexusTurnTwo, nexusReady, nexusAllow, nexusInventory, nexusReload, orderNexusSpice } from './fixture-nexus-cards';
+
+function fixture() {
+ const g = nexusFixture({ advanced: true });
+ g.turn = 2;
+ g.phase = 1;
+ g.storm = 0;
+ for (const p of g.players) {
+ p.forces = {};
+ if (p.elites) p.elites.forces = {};
+ }
+ const cards = g.nexusCards!.cards!;
+ cards.deck = cards.deck.filter(card => card !== 'fremen');
+ cards.discard = cards.discard.filter(card => card !== 'fremen');
+ for (const id of Object.keys(cards.hands))
+ if (cards.hands[id] === 'fremen') cards.hands[id] = null;
+ cards.hands[g.players[0].id] = 'fremen';
+ const fremen = g.players[0];
+ fremen.forces['wind_pass:14'] = 3;
+ fremen.forces['wind_pass:15'] = 2;
+ fremen.elites!.forces['wind_pass:14'] = 1;
+ fremen.elites!.forces['wind_pass:15'] = 2;
+ return { g, fremen, other: g.players[1] };
+}
+
+function appearance(g: Game, territory = 'hagga_basin', event = 'worm-pile-0') {
+ return captureFremenCunningOccurrence(g, {
+ event, territory, origin: 'natural', pile: 0,
+ });
+}
+function accepted(g: Game, occurrence: FremenCunningOccurrence): FremenCunningRideAuthorization {
+ const owner = g.players[0].id;
+ g.nexusCards!.cards!.hands[owner] = null;
+ g.nexusCards!.cards!.discard.push('fremen');
+ return { event: occurrence.event, turn: occurrence.turn, owner };
+}
+const selection: FremenCunningSelection = {
+ source: 'wind_pass',
+ forces: { 'wind_pass:14': { normal: 1, elite: 1 }, 'wind_pass:15': { normal: 0, elite: 2 } },
+ destination: { territory: 'carthag', sector: 11 },
+};
+
+void test('initial public emptiness belongs to an individual appearance, not its post-devour board', () => {
+ const { g, other } = fixture();
+ const empty = appearance(g);
+ assert.equal(empty.initiallyEmpty, true);
+ other.forces['hagga_basin:12'] = 2;
+ const occupied = appearance(g, 'hagga_basin', 'worm-pile-1');
+ assert.equal(occupied.initiallyEmpty, false);
+ delete other.forces['hagga_basin:12']; // worm devoured the defenders
+ assert.equal(quoteFremenCunning(g, g.players[0].id, occupied), null);
+ const quote = quoteFremenCunning(g, g.players[0].id, empty)!;
+ assert.equal(quote.event, 'worm-pile-0');
+ assert.deepEqual(quote.sources, [{ territory: 'wind_pass', sectors: {
+ 'wind_pass:14': { normal: 2, elite: 1 }, 'wind_pass:15': { normal: 0, elite: 2 },
+ } }]);
+ const receipt = accepted(g, empty);
+ assert.deepEqual(validateFremenCunningSelection(g, g.players[0].id, empty, selection, receipt),
+ { total: 4, elite: 3 });
+ assert.equal(g.players[0].forces['wind_pass:14'], 3); // pure quote, no movement
+ assert.equal(quoteFremenCunning(g, g.players[0].id, { ...empty, turn: 1 }), null);
+ assert.equal(quoteFremenCunningRide(g, g.players[0].id, occupied, receipt), null);
+ assert.equal(quoteFremenCunningRide(g, g.players[0].id, empty,
+ { ...receipt, event: 'worm-pile-1' }), null);
+ assert.equal(quoteFremenCunningRide(g, g.players[0].id, empty,
+ { ...receipt, owner: other.id }), null);
+ assert.equal(quoteFremenCunningRide(g, g.players[0].id, empty,
+ { ...receipt, turn: empty.turn - 1 }), null);
+ assert.equal(quoteFremenCunningRide(g, g.players[0].id, { ...empty, turn: 1 }, receipt), null);
+});
+
+void test('advisers and a public No-Field count as initial force presence without reading marker denomination', () => {
+ const { g, other } = fixture();
+ other.faction = 'beneGesserit';
+ other.forces['hagga_basin:13'] = 1;
+ other.advisors = { hagga_basin: {} };
+ assert.equal(appearance(g).initiallyEmpty, false);
+ delete other.forces['hagga_basin:13'];
+ other.faction = 'richese';
+ const marker = { deployed: { location: { territory: 'hagga_basin', sector: 12 } } };
+ Object.defineProperty(marker, 'tokens', { get() { throw new Error('private denomination read'); } });
+ other.noField = marker as typeof other.noField;
+ assert.equal(appearance(g).initiallyEmpty, false);
+ assert.equal(appearance(g, 'carthag').initiallyEmpty, true);
+});
+
+void test('typed one-desert-source subset and ordinary destination occupancy/storm restrictions', () => {
+ const { g, fremen, other } = fixture();
+ const occurrence = appearance(g);
+ const receipt = accepted(g, occurrence);
+ const valid = (move: FremenCunningSelection) =>
+ validateFremenCunningSelection(g, fremen.id, occurrence, move, receipt);
+ const reject = (move: FremenCunningSelection, reason: RegExp) => assert.throws(() => valid(move), reason);
+ reject({ ...selection, forces: { ...selection.forces, 'hagga_basin:12': { normal: 1, elite: 0 } } }, /physical forces/);
+ reject({ ...selection, forces: { 'wind_pass:14': { normal: 3, elite: 0 } } }, /physical forces/);
+ reject({ ...selection, forces: { 'wind_pass:15': { normal: 1, elite: 0 } } }, /physical forces/);
+ reject({ ...selection, forces: { constructor: { normal: 20, elite: 0 } } },
+ /physical forces/);
+ reject({ ...selection, forces: {} }, /at least one/);
+ reject({ ...selection, source: 'carthag' }, /desert/);
+ assert.equal(fremenCunningDestinationBlock(g, fremen.id, occurrence, selection.source,
+ { territory: 'hagga_basin', sector: 12 }), null);
+ reject({ ...selection, destination: { territory: 'wind_pass', sector: 14 } }, /another destination/);
+ reject({ ...selection, destination: { territory: 'carthag', sector: 12 } }, /Arrakis board sector/);
+ g.storm = 11;
+ reject(selection, /storm/);
+ g.storm = 14;
+ assert.equal(quoteFremenCunningRide(g, fremen.id, occurrence, receipt)!.sources[0].sectors['wind_pass:14'], undefined);
+ reject(selection, /physical forces/);
+ g.storm = 0;
+ fremen.ally = other.id;
+ other.ally = fremen.id;
+ other.forces['carthag:11'] = 1;
+ assert.notEqual(fremenCunningDestinationBlock(g, fremen.id, occurrence, selection.source, selection.destination), null);
+ fremen.ally = null;
+ other.ally = null;
+ const third = g.players[2];
+ third.forces['carthag:11'] = 1;
+ assert.notEqual(fremenCunningDestinationBlock(g, fremen.id, occurrence, selection.source, selection.destination), null);
+});
+
+void test('physical Fremen card ownership and native entitlement are required', () => {
+ const { g, fremen, other } = fixture();
+ const occurrence = appearance(g);
+ const cards = g.nexusCards!.cards!;
+ cards.hands[fremen.id] = null;
+ cards.hands[other.id] = 'fremen';
+ assert.equal(quoteFremenCunning(g, fremen.id, occurrence), null);
+ assert.equal(quoteFremenCunning(g, other.id, occurrence), null);
+ assert.throws(() => validateFremenCunningSelection(g, fremen.id, occurrence, selection,
+ { event: occurrence.event, turn: occurrence.turn, owner: fremen.id }), /not available/);
+ cards.hands[other.id] = null;
+ cards.hands[fremen.id] = 'fremen';
+ cards.deck.push('fremen'); // duplicate physical card cannot establish custody
+ assert.throws(() => quoteFremenCunning(g, fremen.id, occurrence), /Nexus/);
+ cards.deck.pop();
+ fremen.ally = other.id;
+ other.ally = fremen.id;
+ assert.equal(quoteFremenCunning(g, fremen.id, occurrence), null);
+});
+
+for (const advanced of [false, true]) void test(
+ `${advanced ? 'Advanced' : 'Basic'} empty worm offers privately, spends once and rides remotely after the Nexus`, () => {
+ let g = nexusTurnTwo({ advanced });
+ const cards = g.nexusCards!.cards!;
+ const index = cards.deck.indexOf('fremen');
+ assert.ok(index >= 0);
+ cards.deck.splice(index, 1);
+ cards.hands.f = 'fremen';
+ const sourceSector = g.storm === 14 ? 15 : 14;
+ g.players[0].reserves--;
+ g.players[0].forces[`wind_pass:${sourceSector}`] = 1;
+ orderNexusSpice(g, ['worm', 'land']);
+ const empty = g.spiceDeck.find(card => 'territory' in card &&
+ g.players.every(player => presenceAt(player, card.territory) === 0));
+ assert.ok(empty && 'territory' in empty);
+ g.spiceDeck.splice(g.spiceDeck.indexOf(empty), 1);
+ if (!('territory' in g.spiceDeck[1])) {
+ const nextLand = g.spiceDeck.findIndex((card, index) =>
+ index > 1 && 'territory' in card);
+ assert.ok(nextLand > 1);
+ g.spiceDeck.splice(1, 0, g.spiceDeck.splice(nextLand, 1)[0]);
+ }
+ g.spiceDiscard[0].push(empty);
+ g = nexusReady(g);
+ assert.equal(g.decision?.kind, 'nexusFremenCunningOffer');
+ const event = g.nexusFremenCunningOffer!.occurrence.event;
+ assert.equal(viewGame(g, 'a').nexusCards?.card, null);
+ assert.equal(viewGame(g, 'f').nexusCards?.card, 'fremen');
+ const karamaIndex = g.deck.findIndex(card => card.effect === 'karama');
+ assert.ok(karamaIndex >= 0);
+ g.players[1].hand.push(g.deck.splice(karamaIndex, 1)[0]);
+ const noCard = nexusReload(g);
+ noCard.nexusCards!.cards!.hands.f = null;
+ noCard.nexusCards!.cards!.deck.push('fremen');
+ assert.equal(viewGame(noCard, 'a').decision?.kind, 'nexusFremenCunningOffer');
+ assert.throws(() => applyAction(noCard, 'f', {
+ type: 'decision', event, accept: true,
+ }), /Fremen Cunning needs/);
+ const passed = applyAction(noCard, 'f', { type: 'decision', event, accept: false });
+ assert.equal(passed.nexusFremenCunningOffer, null);
+ assert.equal(passed.nexusCards!.cards!.hands.f, null);
+ assert.throws(() => applyAction(g, 'f', {
+ type: 'decision', event: 'not-this-worm', accept: true,
+ }), /empty-worm Nexus opportunity/);
+ g = applyAction(nexusReload(g), 'f', { type: 'decision', event, accept: true });
+ assert.equal(g.response?.kind, 'nexusFremenCunning');
+ assert.equal(g.nexusCards!.cards!.hands.f, null);
+ assert.ok(g.nexusCards!.cards!.discard.includes('fremen'));
+ assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), g);
+ const canceled = applyAction(nexusReload(g), 'a', {
+ type: 'card', card: g.players[1].hand[0].id, mode: 'cancel',
+ });
+ assert.equal(canceled.nexusFremenCunningRides?.[0].stage, 'complete');
+ assert.ok(canceled.log.some(line => line.text.includes('Shai-Hulud appeared')));
+ assert.equal(canceled.players[0].forces[`wind_pass:${sourceSector}`], 1);
+ nexusInventory(canceled);
+ g = nexusAllow(g);
+ assert.equal(g.nexusFremenCunningRides?.[0].stage, 'queued');
+ for (let i = 0; !g.decision && g.phase === 1 && i < 6; i++) {
+ if (g.response) g = nexusAllow(g);
+ else g = nexusReady(g);
+ }
+ assert.equal(g.decision?.kind, 'nexusFremenCunningRide');
+ assert.equal(g.nexusFremenCunningRides?.[0].stage, 'select');
+ assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), g);
+ const botView = viewGame(g, 'f');
+ botView.players.find(player => player.id === 'f')!.bot = 'Easy';
+ const botRide = botActions(botView).find(candidate => candidate.accept === true);
+ assert.ok(botRide);
+ const botResult = applyAction(nexusReload(g), 'f', botRide);
+ assert.equal(botResult.nexusFremenCunningRides?.[0].stage, 'complete');
+ nexusInventory(botResult);
+ const action = {
+ type: 'decision' as const, event, accept: true, source: 'wind_pass',
+ forces: { [`wind_pass:${sourceSector}`]: { normal: 1, elite: 0 } },
+ territory: 'polar_sink', sector: 0,
+ };
+ assert.throws(() => applyAction(g, 'f', { ...action, source: 'sietch_tabr' }),
+ /desert territory/);
+ assert.throws(() => applyAction(g, 'f', {
+ ...action, forces: { constructor: { normal: 20, elite: 0 } },
+ }), /physical forces/);
+ const before = nexusReload(g);
+ assert.deepEqual(g, before);
+ g = applyAction(g, 'f', action);
+ assert.equal(g.nexusFremenCunningRides?.[0].stage, 'complete');
+ assert.equal(g.players[0].forces[`wind_pass:${sourceSector}`] ?? 0, 0);
+ assert.equal(g.players[0].forces['polar_sink:0'], 1);
+ assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), g);
+ nexusInventory(g);
+});
+
+void test('an accepted Advanced additional-worm placement offers the distinct empty appearance', () => {
+ let g = nexusTurnTwo({ advanced: true });
+ const cards = g.nexusCards!.cards!;
+ const index = cards.deck.indexOf('fremen');
+ assert.ok(index >= 0);
+ cards.hands.f = cards.deck.splice(index, 1)[0];
+ const sourceSector = g.storm === 14 ? 15 : 14;
+ g.players[0].reserves--;
+ g.players[0].forces[`wind_pass:${sourceSector}`] = 1;
+ const empty = TERRITORIES.find(row => row.type === 'sand' &&
+ g.players.every(player => presenceAt(player, row.id) === 0));
+ assert.ok(empty);
+ orderNexusSpice(g, ['worm', 'land']);
+ const priorWorm = g.spiceDeck.findIndex((card, i) => i > 1 && 'worm' in card);
+ assert.ok(priorWorm > 1);
+ g.spiceDiscard[0].push(g.spiceDeck.splice(priorWorm, 1)[0]);
+ g = nexusReady(g);
+ assert.equal(g.decision?.kind, 'wormPlacement');
+ g = applyAction(g, 'f', { type: 'decision', accept: true, territory: empty.id });
+ g = nexusAllow(g);
+ assert.equal(g.decision?.kind, 'nexusFremenCunningOffer');
+ const occurrence = g.nexusFremenCunningOffer!.occurrence;
+ assert.equal(occurrence.origin, 'additional');
+ assert.equal(occurrence.territory, empty.id);
+ assert.equal(occurrence.initiallyEmpty, true);
+ assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), g);
+ g = applyAction(g, 'f', { type: 'decision', event: occurrence.event, accept: true });
+ g = nexusAllow(g);
+ assert.equal(g.nexusFremenCunningRides?.[0].stage, 'queued');
+ assert.equal(g.nexusFremenCunningRides?.[0].occurrence.event, occurrence.event);
+ assert.equal(g.nexusCards!.cards!.hands.f, null);
+ nexusInventory(g);
+});
diff --git a/vite.config.ts b/vite.config.ts
index 7d493c99..d38088ad 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,9 +1,29 @@
+import { execFileSync } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
import { sites } from '@openai/sites-vite-plugin';
import tailwindcss from '@tailwindcss/postcss';
import vinext from 'vinext';
import { defineConfig } from 'vite';
import hostingConfig from './.openai/hosting.json';
+const fullGitSha = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i;
+const configuredRevision = process.env.BUILD_REVISION?.trim();
+let buildRevision = 'unavailable';
+if (configuredRevision && fullGitSha.test(configuredRevision)) {
+ buildRevision = configuredRevision;
+} else {
+ try {
+ const checkoutRevision = execFileSync('git', ['rev-parse', 'HEAD'], {
+ cwd: fileURLToPath(new URL('.', import.meta.url)),
+ encoding: 'utf8',
+ stdio: ['ignore', 'pipe', 'ignore'],
+ }).trim();
+ if (fullGitSha.test(checkoutRevision)) buildRevision = checkoutRevision;
+ } catch {
+ // Container contexts exclude .git; only an explicit workflow SHA can identify them.
+ }
+}
+
const SITE_CREATOR_PLACEHOLDER_DATABASE_ID =
'00000000-0000-4000-8000-000000000000';
@@ -45,6 +65,7 @@ export default defineConfig(async () => {
const { cloudflare } = await import('@cloudflare/vite-plugin');
return {
+ define: { __BUILD_REVISION__: JSON.stringify(buildRevision) },
css: { postcss: { plugins: [tailwindcss()] } },
server: {
watch: {