Connect native Fremen Nexus effects across selected modules

Retain actual Cunning appearances, paid Karama and typed remote riding with optional Sandmaster collection and source-parent HMS entry. Preserve Betrayal movement sources and apply its declared worm restriction to Great Maker reserve riding without suppressing votes or casualties. Completed Maker history no longer blocks a later ordinary offer.

Validation: frozen 14-file86/86, types/lint/build, nine finite original-action/rendered-control programmes, four minimal legal policies. Discovery destination proof remains focused quotes pending actual arrival; full combinations, assurance and deployed acceptance remain required. No saved-game, restart or deployment operation.
This commit is contained in:
Quintine committed 2026-10-18 11:35:59 +11:00
1 parent bb9d292c4c
commit b4157aaa9b
20 files changed
+1349 -41

No files matched your search

+18 -4
View File
@@ -101,7 +101,8 @@ import { PlanetologistMovement } from './planetologist-movement';
import { selectedOriginElites, advisorFlipMovementBlocked } from '@/game/planetologist-movement';
import { SandmasterMovement } from './sandmaster-movement';
import { SandmasterWormChoice } from './sandmaster-worm';
import { sandmasterWormCollection } from '@/game/sandmaster-worm';
import { sandmasterWormCollection, sandmasterCunningCollection } from '@/game/sandmaster-worm';
import { fremenCunningPublicDestinationBlock } from '@/game/nexus-fremen-cunning';
import { ResidualPoison, BattleLeaderOpportunity } from './residual-poison';
import { PortableSnooper } from './portable-snooper';
import {
@@ -536,12 +537,18 @@ export function GameTable({
const [rideForces, setRideForces] = useState<Record<string, number>>({});
const [collectSandmasterRide, setCollectSandmasterRide] = useState(true);
const [sandmasterRidePile, setSandmasterRidePile] = useState<{ target: string; key: string } | null>(null);
const sandmasterRideTarget = JSON.stringify([g.turn, g.decision?.kind === 'wormRide' ? g.decision : null, selected, sector]);
const baseSandmasterRide = sandmasterWormCollection(g, me.id, selected, sector);
const sandmasterRideTarget = JSON.stringify([g.turn,
g.decision?.kind === 'wormRide' || g.decision?.kind === 'nexusFremenCunningRide' ? g.decision : null,
source, selected, sector]);
const baseSandmasterRide = g.decision?.kind === 'nexusFremenCunningRide'
? sandmasterCunningCollection(g, me.id, source, selected, sector)
: sandmasterWormCollection(g, me.id, selected, sector);
const selectedRidePile = sandmasterRidePile?.target === sandmasterRideTarget
? baseSandmasterRide?.piles.find(pile => pile.key === sandmasterRidePile.key) : null;
const sandmasterRide = selectedRidePile && baseSandmasterRide
? { ...baseSandmasterRide, key: selectedRidePile.key, before: selectedRidePile.before } : baseSandmasterRide;
const cunningDestinationBlocked = g.decision?.kind === 'nexusFremenCunningRide' && source
? fremenCunningPublicDestinationBlock(g, me.id, source, { territory: selected, sector }) : null;
const [exchangeCards, setExchangeCards] = useState<Record<string, boolean>>(
{},
);
@@ -3828,16 +3835,23 @@ export function GameTable({
)}
</div>)}
{destination}
{cunningDestinationBlocked && <p className="notice">{cunningDestinationBlocked}</p>}
<SandmasterWormChoice quote={sandmasterRide} collect={collectSandmasterRide}
onChange={setCollectSandmasterRide}
onPileChange={key => setSandmasterRidePile({ target: sandmasterRideTarget, key })} />
{actionButton('Ride remotely', {
type: 'decision', event: g.decision.event, accept: true,
source, territory: selected, sector,
...(sandmasterRide?.blocked === null
? { sandmasterCollect: collectSandmasterRide,
...(collectSandmasterRide ? { sandmasterPile: sandmasterRide.key } : {}) } : {}),
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]) =>
}, !source || !!cunningDestinationBlocked || !Object.entries(rideForces).some(([key, count]) =>
count > 0 && splitLocation(key).territory === source))}
{actionButton('Decline remote ride', {
type: 'decision', event: g.decision.event, accept: false,
+4
View File
@@ -21,6 +21,10 @@ export function NexusFremenBetrayal({ game, act, busy }: Props) {
? `Declare before the first spice blow: ${fremen.name} cannot take ordinary or Cunning worm rides this turn. Worm destruction, survival, protection and Nexus still resolve.`
: `This use suppresses ${fremen.name}’s native two-territory movement advantage for the rest of this turn. Independent ornithopters remain available.`}
</p>}
{offer.mode === 'worm' && game.discoveries && <p className="fine">
Provisional Great Maker interpretation: its reserve ride is also prevented;
the original vote, worm effects and Nexus continue.
</p>}
{offer.blocked && <p className="notice">{offer.blocked}</p>}
<Button className="game-action min-h-11 whitespace-normal" disabled={busy || !action}
onClick={() => { if (!busy && action) act(action); }}>
+29
View File
@@ -16,6 +16,35 @@ open mode gates.
## Current checkpoint and work
**Native Fremen Nexus modules — 18 October. Prototyped / Partial.**
Cunning and Betrayal now retain the original registered Nexus roster and selected
modules. Actual drawn Cunning, original Karama allowance/cancellation and the
typed post-negotiation ride keep their source and normal allowances. A living
native Sandmaster may collect one earned destination spice. Original HMS entry
requires its actual pointer to target the selected desert; Discovery destinations
have focused quote coverage, with their actual arrival smoke still due.
Great Maker reserve riding now respects the predeclared worm restriction under
the explicit source composition. Votes, original casualties and Nexus continue;
the completed Maker no longer blocks a later ordinary empty-worm offer. Native
movement suppression leaves ordinary range1 and earned city range3 available.
Three disjoint lanes plus owner integration pass frozen **14 files86/86**,
types/lint/build and nine finite original-action/rendered-control programmes.
Basic selected Ix/CHOAM rides use160/100 actions; six-seat World/all14/phase
Sandmaster and actual HMS each373. Movement Basic/Advanced uses120+29/127+27,
earned city129+27; Maker source95 plus20 prevented/21 unspent, and a separate
later empty appearance95+21. Four policies retain legal Cunning and both
Betrayal declarations; no tuning/calibration. Framework aliases/CSS stubs and
a directly rendered actual Sandmaster quote mean no visual/browser acceptance.
Iteration fixed the cleared-decision Sandmaster adapter and corrected fixtures
to buy a real Karama, distinguish negotiations from the closing card draw,
and retain the subsequent Advanced pile. No printed numeric rule changed.
No saved room/database, reset, restart or deployment. Remaining dynamic arrival,
Nexus/Discovery/Semuta interactions, full modes and comprehensive assurance stay
open. [Source-bound proof and limits](NEXUS_CARD_RULES.md#native-fremen-nexus-modules--18-october-2026).
**Native Ecaz Betrayal / Moritani Atomics — 18 October. Prototyped / Partial.**
Selected modules now retain typed native returns, advisors, actual shared HMS,
face-down concealed recall and original paid Karama cancellation. Guild first/
+18
View File
@@ -1,5 +1,23 @@
# Dune implementation status
## 18 October 2026 — Native Fremen Nexus modules
Original registered Nexus rosters now retain Cunning/Betrayal with selected
modules. Actual empty appearances, paid Karama, typed remote riding, native
Sandmaster and source-parent HMS entry use original sources and controls.
Great Maker respects predeclared worm prevention while its votes/casualties/
Nexus continue; a completed encounter no longer blocks a later ordinary offer.
Native range suppression preserves ordinary movement and earned city flight.
Three exclusive lanes plus owner integration pass frozen14-file86/86,
types/lint/build, nine finite original-action/rendered-control programmes and
four legal policies. Fixed a cleared-decision Sandmaster adapter; corrected
actual counter acquisition, Nexus timing, coalition and subsequent-blow test
assumptions without changing printed numeric rules.
[Source-bound evidence and limits](NEXUS_CARD_RULES.md#native-fremen-nexus-modules--18-october-2026).
Discovery destinations remain quote evidence pending actual arrival acceptance;
no complete modes, broad assurance, calibration or deployment claim.
## 18 October 2026 — Native Betrayal and Atomics composition
Ecaz Betrayal now retains selected native modules, typed World returns,
+55
View File
@@ -2,6 +2,61 @@
Original source audit, 10 September 2026, with dated bounded runtime follow-ups below. This document establishes the printed component inventory, source protocol and implementation boundaries; none lifts expansion release gates. The ordinary worm-triggered alliance Nexus already in the engine is distinct from this optional card module.
## Native Fremen Nexus modules — 18 October 2026
**Prototyped / Partial.** Native Fremen Cunning and Betrayal now use original
initialized Nexus and registered selected rosters with their original optional
modules. Existing host/setup prerequisites remain; no played-save conversion
or free held face is supplied. Genuine first closing draws, paid counter cards
and accepted empty appearances bind the original response and typed remote ride.
Ordinary movement and reserve allowances remain unchanged. A living native
Sandmaster may optionally collect one destination spice through the original
pile quote; a declined/omitted collection still rides.
Revealed Discovery destinations and source-parent HMS geometry share the public
human/bot/server destination calculation. HMS has genuine first-Storm placement
and typed arrival proof; Discovery has focused pure quote evidence, with actual
arrival acceptance still due. Native movement Betrayal leaves ordinary range1
and earned city range3 intact. Predeclared worm prevention also suppresses Great
Maker reserve riding under the explicit source composition, while votes,
casualties and Nexus continue. A completed Maker does not block a later ordinary
empty-worm offer. The replacement event adds no second Cunning board ride.
[Source/timing and limits](NEXUS_FREMEN_RULES.md#native-modules-remote-riding-and-great-maker--18-october-2026),
[Sandmaster](SANDMASTER_WORM.md#native-cunning-remote-ride--18-october-2026),
[Great Maker composition](RULE_DECISIONS.md#fremen-nexus-and-great-maker-riding--18-october-2026).
Three exclusive helper/fixture lanes plus owner engine/Sandmaster/bot/table
integration pass frozen **14 files86/86**, types/lint/build and nine finite
original-action/rendered-control programmes. Basic selected Ix/CHOAM Cunning
uses160/100 actions; original six-seat World/all14/phase Sandmaster and HMS
each373. Restricted movement Basic/Advanced uses120+29/127+27, earned city129+27;
Maker source95 plus20 prevented/21 unspent, and later empty-worm source95+21.
Four minimal policies retain actual Cunning and both Betrayal declarations;
no strategy refinement or strength calibration.
Actual table SSR retains spend, response/ride, typed-source, HMS, Betrayal and
Great Maker controls. The shared Sandmaster checkbox is additionally rendered
from the genuine pending quote. Framework aliases/CSS stubs mean no visual/
browser acceptance. Private four-step proof is
`20261018-fremen-nexus-modules-frozen/report.json`, with runtime facts in
`20261018-fremen-nexus-modules/runtime-smoke.json`. Later checkpoint prose and
checklist evidence are a documented overlay; application/tests stayed frozen.
Iteration fixed the real engine-cleared-decision adapter. Fixtures now buy an
actual one-spice Karama instead of expecting a fictional response, preserve
original post-negotiation/closing-draw timing, distinguish an Ecaz coalition
from an ordinary ally and allow the actual next Advanced spice blow to continue.
A range rejection assertion now matches the actual one-territory reason. No
printed numeric rule changed. No saved room/database, reset, restart or
deployment. Reactive post-appearance timing, dynamic arrival combinations,
complete modes, comprehensive assurance and deployed acceptance remain required.
Evidence: `game/fremen-nexus-profile.ts`, `game/nexus-fremen-cunning.ts`,
`game/nexus-fremen-betrayal.ts`, `game/sandmaster-worm.ts`,
`tests/fremen-cunning-modules.test.ts`, `tests/fremen-betrayal-modules.test.ts`,
their original-action fixtures and
`tests/nexus-fremen-cunning-modules-quotes.test.ts`.
## Native Betrayal and Atomics composition — 18 October 2026
**Prototyped / Partial.** Ecaz Betrayal now uses
+59
View File
@@ -2,6 +2,58 @@
Primary-source and integration audit, 10 September 2026, with dated Secret Ally revival, protection and native Cunning prototypes. These bounded paths do not certify complete Fremen/Nexus effects or open a release gate. The [common Nexus rules](NEXUS_CARD_RULES.md) apply.
## Native modules, remote riding and Great Maker — 18 October 2026
**Prototyped / Partial.** Native Fremen Nexus
effects now use the original initialized card module and registered selected
roster rather than a classic-only configuration gate. Original host setup and
module prerequisites still apply; no played game is converted or given a card.
Worlds, training, phase modules and selected independent decks retain the same
actual appearance, physical card cost, Betrayal declaration and typed ride.
An ordinary initially empty worm offers the same public native Cunning choice
whether Fremen holds the card or not. Actual entitlement controls spending;
the original worm, applicable Nexus and ordinary rides finish before the
authenticated remote ride. All selected normal/Fedaykin counters come from one
printed desert; reserves, Homeworlds and Discovery strongholds are not desert
sources. Room-local revealed Discovery destinations use their actual locations.
HMS entry requires its actual pointer's parent to match the chosen source
territory; its structure is not moved by the ride. Hidden/unplaced destinations,
storm and occupancy retain their own entry restrictions.
A living available native Sandmaster can collect one spice at the remote ride's
destination. Collection is optional and uses the original shared destination-
pile quote; multiple source sectors do not multiply it. No source or imagined
intermediate territory supplies spice. The same multi-pile choice convention
as ordinary worm riding remains provisional. This still uses neither ordinary
movement nor a reserve shipment.
The [publisher E3 rules, printed12](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=12)
and supplied revision physical23/logical21 replace ordinary board-origin
riding with Great Maker reserve riding; native Karama killing the board group
does not cancel that reserve ride.
Under the recommended source composition, predeclared turn-long Fremen Nexus
worm-riding prevention therefore also suppresses the reserve ride; votes,
destruction, spice and the actual Nexus continue. This is not a newly located
Nexus/Great Maker FAQ or a Karama cancellation. The replacement encounter itself
does not invent another Cunning board ride. Its completed historical frame does
not suppress a later ordinary empty-worm appearance.
Source and pure helpers: `game/fremen-nexus-profile.ts`,
`game/nexus-fremen-cunning.ts`, `game/nexus-fremen-betrayal.ts`,
`game/sandmaster-worm.ts`. Original-action fixtures and focused module cases
accompany these helpers; complete combinations, comprehensive assurance,
full AI and deployed acceptance remain open.
The owner-frozen **14 files86/86**, types/lint/build and nine finite original-
action/table-SSR programmes pass. Original paid Karama supplies a real response;
typed World/all14 riding, Sandmaster, HMS and both Betrayal modes use actual
setup/actions. A genuine Maker/second-pile sequence proves the completed-frame
fix. Four legal policies retain Cunning and both declarations. Discovery
destinations have focused pure quote evidence; their genuine arrival and
comprehensive combinations remain due. [Shared checkpoint](NEXUS_CARD_RULES.md#native-fremen-nexus-modules--18-october-2026)
records counts, fixture corrections and render/deployment limits.
## Evidence
The Fremen panel was freshly read from the [photograph of the printed GF9 cards](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani), cached as `/tmp/dune-nexus-cards.jpg`. Cunning permits a remote ride when a sandworm appears in a territory containing no forces: the player may take forces from one desert territory to a destination respecting storm and occupancy rules. Secret Ally offers either protection against a sandworm or a total of three free force revivals during Revival. The latter does **not** use Emperor's additional/beyond-limits wording. Neither option grants the absent faction's other advantages.
@@ -14,6 +66,10 @@ The [E1 FAQ, p.11](https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/
## Bounded Betrayal: movement and worm riding
The initial classic-only envelope below is historical; the native module
extension above supersedes it. Its advance-declaration timing remains a
prototype boundary rather than a universal reactive-priority ruling.
An unallied non-Fremen holder of the physical Fremen Nexus card may spend it
during Shipment and Movement **before Fremen move** to suppress their native
two-territory movement advantage for the rest of that turn. This bounded path
@@ -79,6 +135,9 @@ Two interactions need a concrete decision before treating this as a complete fea
### Connected native Cunning path
The initial module exclusions below are historical; the native extension above
supersedes them. Original appearance, cancellation and ride ownership remain.
`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 and Nexus, optionally retaining original Tech Tokens3+ and/or Advanced Stronghold Cards2+. Skills, Homeworlds, Discovery and expansion factions/decks stay outside this Cunning boundary. 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.
+16
View File
@@ -2,6 +2,22 @@
Updated 18 October 2026. This is the authoritative navigation index for existing decisions, not a replacement rulebook or a new ruling. Detailed linked contracts retain their source citations and exact supported boundaries. Runtime follow-ups supersede historical absence claims; passing tests establish software behavior, not publisher authority.
## Fremen Nexus and Great Maker riding — 18 October 2026
Publisher E3printed12 and the supplied Advanced revision physical23/logical21
replace ordinary board-origin riding/Sandworm placement with Great Maker reserve
riding; native Karama killing the board group does not cancel that reserve ride.
Fremen Nexus Betrayal's distinct turn-long worm-
riding prohibition therefore also suppresses this reserve ride under the
14 October choose-recommended instruction. It leaves original votes, casualties,
spice and Nexus untouched. This is a source composition, not an explicit
publisher Nexus/Great Maker clarification or an extension of Karama.
Actual Great Maker appearances do not open an additional Cunning board ride;
their completed historical frame does not block a later ordinary worm. Original
ordinary Cunning and its authenticated sources retain their own continuation.
[Contract, controls and limits](NEXUS_FREMEN_RULES.md#native-modules-remote-riding-and-great-maker--18-october-2026).
## Provisional No-Field return and Atomics — 18 October 2026
The supplied Advanced revision physical35/printed33 sends all territory forces
+29
View File
@@ -1,5 +1,34 @@
# Sandmaster worm-ride collection
## Native Cunning remote ride — 18 October 2026
**Prototyped / Partial.** The original authenticated
Fremen Nexus Cunning ride can optionally collect one destination spice using
`sandmasterCunningCollection(game, player, source, destination, sector, selectedPile?)`.
It requires the living available native trainer and the actual pending remote
ride. The selected source must be an occupied printed desert; the already
resolved empty worm's territory is not substituted for that source.
The accepted ordinary Cunning action adds the same optional `sandmasterCollect`
and `sandmasterPile` fields as native worm riding. The engine requotes the
typed source, destination and original positive pile before moving any force or
spice. Collection debits one destination pile once, regardless of force count
or source sectors. Omitted/false collection preserves the underlying ride;
invalid collection rejects the request. This does not spend normal movement,
touch World reserves, or collect from a source or invented intermediate route.
The original shared checkbox/pile controls and minimal bot choices use the same
quote. Original multi-pile selection remains a provisional allocation policy;
[Fremen Nexus timing and boundaries](NEXUS_FREMEN_RULES.md#native-modules-remote-riding-and-great-maker--18-october-2026)
remain separate from complete module and deployed acceptance.
Owner-frozen related14-file86/86, types/lint/build, genuine six-seat trained
World Cunning and actual HMS programmes pass. Destination6→5 pays the native
trainer one spice; false/omitted collection still rides, and a stale pile or
nonboolean flag rejects. All four policies retain legal original choices.
The actual shared checkbox is rendered from its pending source-bound quote;
table SSR is not visual/browser acceptance. [Shared proof and limits](NEXUS_CARD_RULES.md#native-fremen-nexus-modules--18-october-2026).
20 September 2026. **Prototyped**, with **Partial** rules coverage. Native
Fremen worm rides connect optional destination collection in Basic/Advanced
Leader Skills development games. Public module and publication gates stay closed.
+8 -4
View File
@@ -99,7 +99,8 @@ import {
} from './bot-mobility';
import { planetologistMovementModeSupported, planetologistLeader, planetologistGatherAdvisorEntry } from './planetologist-movement';
import { quoteSandmasterStronghold, sandmasterDefaultChoice, sandmasterDestinationCollection } from './sandmaster-movement';
import { sandmasterWormCollection } from './sandmaster-worm';
import { sandmasterWormCollection, sandmasterCunningCollection } from './sandmaster-worm';
import { fremenCunningPublicDestinationBlock } from './nexus-fremen-cunning';
import { richeseCardDefinition } from './richese-cards';
import { presenceAt } from './force-presence';
import { botArrivalBlock, botWormRideArrivalBlock } from './bot-arrival';
@@ -2763,10 +2764,13 @@ function policyActions(g: GameView): Action[] {
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;
if (fremenCunningPublicDestinationBlock(g, me.id, source,
{ territory: to.t, sector: to.s }) || !botEntryAllowed(g, me, to.t, to.s, 'move')) continue;
const sandmaster = sandmasterCunningCollection(g, me.id, source, to.t, to.s);
candidates.push({ type: 'decision', event: d.event, accept: true,
source, forces, territory: to.t, sector: to.s });
source, forces, territory: to.t, sector: to.s,
...(sandmaster?.blocked === null && sandmaster.key
? { sandmasterCollect: true, sandmasterPile: sandmaster.key } : {}) });
if (candidates.length >= 16) return [...candidates, decline];
}
}
+29 -8
View File
@@ -23,7 +23,7 @@ import { quoteSmugglerNoField, smugglerNoFieldModeSupported, type SmugglerNoFiel
import { quoteSmugglerShipment, type SmugglerShipment } from './smuggler-shipment';
import { createSmugglerBattle, settleSmugglerBattle, smugglerBattleModeSupported, smugglerBattlePlanBlock, smugglerBattlePile, smugglerBattleSignature, smugglerBattleAllocationRequired, chooseSmugglerBattleAllocation, type SmugglerBattleReceipt, type SmugglerBattleCollectionOffer } from './smuggler-battle';
import { quoteSandmasterMovement, validateSandmasterMovement, sandmasterCollectionPiles, sandmasterLeader, sandmasterRouteDistance, validateSandmasterStronghold, type SandmasterMovement, type SandmasterOrder } from './sandmaster-movement';
import { sandmasterWormCollection } from './sandmaster-worm';
import { sandmasterWormCollection, sandmasterCunningCollection } from './sandmaster-worm';
import { spiceBankerModeSupported, validateSpiceBankerSpend } from './spice-banker';
import { BankerIncomeError, createBankerIncomeState, validateBankerIncomeState, quoteBankerIncome, commitBankerIncome, quoteBankerIncomeCollection, commitBankerIncomeCollection, projectBankerIncome, type BankerIncomeState, type BankerIncomeAuthority, type BankerIncomeContext } from './spice-banker-income';
import { quoteDiplomatDefense, diplomatDefenseModeSupported, diplomatDefenseLabel, type DiplomatDefenseQuote } from './diplomat-defense';
@@ -49,6 +49,7 @@ import { emperorNexusPurchaseEvent, quoteEmperorNexusPurchase, signEmperorNexusP
import { createEmperorPaymentReceipt, validateEmperorPaymentReceipt, emperorPaymentUseBlock, emperorPaymentSourceQuote, requiredEmperorPaymentResponders, emperorPaymentReceiptSignature, type EmperorPaymentReceipt } from './nexus-emperor-payment';
import { FREMEN_NEXUS_FREE_FORCES, fremenNexusRevivalOffer, createFremenNexusRevival, upgradeFremenNexusRevival, validateFremenNexusRevival, type FremenNexusRevival } from './nexus-fremen-revival';
import { captureFremenCunningOccurrence, quoteFremenCunning, quoteFremenCunningRide, validateFremenCunningSelection, type FremenCunningOccurrence, type FremenCunningRideAuthorization, type FremenCunningSelection } from './nexus-fremen-cunning';
import { fremenNexusModeSupported } from './fremen-nexus-profile';
import { createNexusFremenProtection, currentNexusFremenProtectionOwner, answerNexusFremenProtection, validateNexusFremenProtection, type NexusFremenProtectionFrame, type NexusFremenProtectionInput } from './nexus-fremen-protection';
import { truthKnowledgeOf } from './truthtrance-knowledge';
import { createMoritaniAssassinateOpportunity, moritaniAssassinateTrigger, moritaniAssassinateChoices, quoteMoritaniAssassinate, moritaniAssassinateSignature, validateMoritaniAssassinate, type MoritaniAssassinateState, type MoritaniAssassinateReceipt } from './moritani-assassinate';
@@ -11425,10 +11426,7 @@ function beginWorm(g: Game, t: string, origin: 'natural' | 'additional' | 'summo
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.players.every(seat => ['atreides', 'harkonnen', 'emperor', 'guild', 'beneGesserit', 'fremen'].includes(seat.faction)) &&
(!g.greatMaker || g.greatMaker.stage === 'complete') && fremenNexusModeSupported(g) &&
fremen && !fremen.ally && g.phase === 1 &&
fremen.fremenNexusWormBlockedTurn !== g.turn &&
Object.entries(fremen.forces).some(([key, amount]) => {
@@ -11882,7 +11880,8 @@ function decideDiscoveryDiscard(g: Game, p: Player, action: Action) {
function greatMakerRideOptions(g: Game, owner: string) {
const p = getPlayer(g,owner), frame = g.greatMaker;
if (frame?.stage !== 'ride' || p.faction !== 'fremen') return null;
if (frame?.stage !== 'ride' || p.faction !== 'fremen' ||
p.fremenNexusWormBlockedTurn === g.turn) return null;
const destinations = gameTerritories(g).flatMap(t => t.sectors.flatMap(sector => {
try { allowedEntry(g,p,t.id,sector); return [{territory:t.id,name:t.name,sector}]; }
catch (error) { if (error instanceof RuleError) return []; throw error; }
@@ -11963,7 +11962,7 @@ function decideGreatMakerVote(g: Game, p: Player, action: Action) {
log(g, 'A majority voted yes: the Great Maker produces a Nexus.');
} else log(g, 'The Great Maker vote has no yes majority, so this encounter adds no Nexus.');
const fremen = byFaction(g, 'fremen');
if (fremen && fremen.reserves > 0) {
if (fremen && fremen.reserves > 0 && fremen.fremenNexusWormBlockedTurn !== g.turn) {
frame.stage = 'ride'; stampGreatMaker(g);
g.decision = {kind:'greatMakerRide', player:fremen.id, event:frame.event};
} else completeGreatMaker(g);
@@ -11972,6 +11971,8 @@ function decideGreatMakerRide(g: Game, p: Player, action: Action) {
const frame = g.greatMaker!;
requireRule(frame?.stage === 'ride' && frame.event === action.event && p.faction === 'fremen' && typeof action.accept === 'boolean',
'Choose the current Great Maker reserve ride.');
requireRule(p.fremenNexusWormBlockedTurn !== g.turn,
'Fremen Nexus Betrayal prevents worm riding this turn.');
if (!action.accept) {
requireRule(Object.keys(action).every(key => ['type','event','accept'].includes(key)), 'Declining a ride selects no forces or destination.');
log(g, `${p.name} left the optional Great Maker riders in reserves.`);
@@ -32308,8 +32309,13 @@ function applyActionInner(
'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)),
['type', 'event', 'accept', 'source', 'forces', 'territory', 'sector', 'sandmasterCollect', 'sandmasterPile'].includes(key)),
'Choose only this Cunning source, forces and destination.');
requireRule(action.sandmasterCollect === undefined || typeof action.sandmasterCollect === 'boolean',
'Choose whether to collect spice with Sandmaster.');
requireRule(action.sandmasterPile === undefined ||
(action.sandmasterCollect === true && typeof action.sandmasterPile === 'string'),
'Choose a destination spice pile only with Sandmaster collection.');
const selection = {
source: stringField(action.source),
forces: action.forces as FremenCunningSelection['forces'],
@@ -32321,6 +32327,15 @@ function applyActionInner(
const chosen = nexusRule(() => validateFremenCunningSelection(
g, id, ride.occurrence, selection, ride.authorization));
allowedEntry(g, p, selection.destination.territory, selection.destination.sector);
const sandmaster = action.sandmasterCollect ? sandmasterCunningCollection(g, id,
selection.source, selection.destination.territory, selection.destination.sector,
action.sandmasterPile as string | undefined, decision) : null;
if (action.sandmasterCollect) {
requireRule(sandmaster && !sandmaster.blocked && sandmaster.key,
sandmaster?.blocked ?? 'Sandmaster collection needs the living native trainer.');
requireRule(sandmaster.piles.length <= 1 || typeof action.sandmasterPile === 'string',
'Choose the destination spice pile for Sandmaster collection.');
}
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])));
@@ -32329,6 +32344,12 @@ function applyActionInner(
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' });
if (sandmaster) {
g.spice[sandmaster.key!] = sandmaster.before - 1;
p.spice++;
log(g, `${p.name}'s Sandmaster collected 1 spice at the remote worm-ride destination. This ride does not consume normal movement.`,
{ faction: p.faction, name: 'Sandmaster collection' });
}
const intruded = intrusion(g, p, selection.destination.territory, { wormRide: true });
if (openTerritoryEntry(g, p, selection.destination.territory,
selection.destination.sector, chosen.total, chosen.elite,
+18
View File
@@ -0,0 +1,18 @@
import { FACTIONS } from './catalog';
import type { Game } from './engine';
/** Native Nexus effects use the original initialized module and registered
* roster. Their own occurrence, card, timing and arrival checks remain separate;
* selecting another module does not remove the printed Fremen card effect. */
export function fremenNexusModeSupported(game: Game): boolean {
return !!game.nexusCards?.cards && typeof game.advanced === 'boolean' &&
game.players.length >= (game.techTokens ? 3 : 2) && game.players.length <= 6 &&
game.players.some(player => player.faction === 'fremen') &&
new Set(game.players.map(player => player.faction)).size === game.players.length &&
game.expansions.length <= 3 &&
game.expansions.every((family, index) => ['ix', 'choam', 'ecaz'].includes(family) &&
game.expansions.indexOf(family) === index) &&
(!game.strongholdCards || game.advanced) &&
game.players.every(player => FACTIONS.some(faction => faction.id === player.faction &&
(faction.expansion === 'base' || game.expansions.includes(faction.expansion))));
}
+3 -7
View File
@@ -1,6 +1,6 @@
import type { Game } from './engine';
import { nexusCleanPlayBlocked } from './nexus-play-boundary';
import { nexusCoreProfile } from './faction-module-profile';
import { fremenNexusModeSupported } from './fremen-nexus-profile';
export type FremenNexusBetrayal = {
event: string;
@@ -40,12 +40,8 @@ export function fremenBetrayalOffer(
if (holder.ally) blocked = 'An allied player cannot use a Nexus card.';
else if (g.status !== 'playing' || (g.phase !== 1 && g.phase !== 5))
blocked = 'Use Fremen Betrayal before the first Spice Blow or before Fremen movement.';
else if (!nexusCoreProfile(g) &&
(g.expansions.length || g.homeworlds || g.leaderSkills || g.discoveryEnabled ||
g.discoveries || g.discoveryStash || g.greatMaker || g.ecazTreachery ||
g.techTokens || g.strongholdCards ||
g.players.some(p => !['atreides','harkonnen','emperor','fremen','guild','beneGesserit'].includes(p.faction))))
blocked = 'Combined expansion and module interactions remain unavailable.';
else if (!fremenNexusModeSupported(g))
blocked = 'This Fremen Nexus roster or module prerequisite is unavailable.';
else if (nexusCleanPlayBlocked(g, automaticPending))
blocked = 'Finish the current interaction before using Fremen Betrayal.';
else if (g.nexusFremenBetrayalHistory?.some(record => record.turn === g.turn))
+27 -11
View File
@@ -1,5 +1,5 @@
import { TERRITORIES, splitLocation, validGameLocation } from './board';
import type { Game, Player } from './engine';
import { gameTerritories, MOBILE_STRONGHOLD, TERRITORIES, splitLocation, validGameLocation } from './board';
import type { Game, GameView, Player } from './engine';
import { presenceAt } from './force-presence';
import { nexusCardMode, validateNexusCards } from './nexus-cards';
import { territoryEntryBlock } from './occupancy';
@@ -118,19 +118,35 @@ export function quoteFremenCunningRide(
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. */
/** Public destination legality for the human and minimal bot adapters. This
* does not authenticate an appearance, held card or selected source counters. */
export function fremenCunningPublicDestinationBlock(
g: Game | GameView, owner: string,
source: string, destination: FremenCunningSelection['destination'],
): string | null {
const { territory: to, sector } = destination;
if (!gameTerritories(g).some(t => t.id === to) || !validGameLocation(g, to, sector))
return 'Choose an Arrakis board sector.';
if (to === source)
return 'Choose another destination territory.';
if (to === MOBILE_STRONGHOLD) {
const pointer = g.mobileStronghold?.location;
const parent = pointer ? splitLocation(pointer) : null;
if (!parent || !TERRITORIES.some(t => t.id === parent.territory) ||
!validGameLocation(g, parent.territory, parent.sector) || parent.territory !== source)
return 'Ride into the mobile stronghold only from the territory it points at.';
}
if (sector !== 0 && sector === g.storm) return 'That sector is in storm.';
return territoryEntryBlock(g.players, owner, to);
}
/** Preserve the original occurrence-taking API. Authentication stays in the
* saved ride quote; all destination consumers share the public calculation. */
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);
return fremenCunningPublicDestinationBlock(g, owner, source, destination);
}
/** Requote immediately before committing, while the authenticated appearance
+7 -7
View File
@@ -4366,7 +4366,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Connected native outcomes use original sources: Suboid Cunning and winning trained Suk settle typed fixed casualties before one equal actual Cyborg Tank substitution; original cleanup and mandatory winner Tech precede matching Face Dance, retaining its foreign-Homeworld suppression. Signed Richese two-marker one-invoice physical shipment requires a currently revealed nested site. Ecaz-selected coalition armies and Moritani skill-first assassination remain distinct from shared-Duke training. These programmes do not certify every skill or card band.',
'The original nexus --discoveries entry retains NO Skills with classics/base33, single or paired one-family E1/E2, both selected Ix+CHOAM native families/union47, or standalone OR paired Ecaz/Moritani/ecaz33 with classics. Original Homeworlds/Tech3+/Advanced Strongholds remain optional. Real native Traitors, Arrakis Occupy and Advanced assassination marks remain; no starting Nexus hand or training is added. Maker losses/votes/typed reserve rides, both Advanced piles and actual settled alliance change precede the closing card deal. Source guards and public/played-save boundaries remain.',
'Original Stronghold Cards are claimed only at end-Mentat. A held benefit applies in its named territory or the mobile stronghold through its actual declared copy; revealed Discovery locations do not inherit another territory’s defense or bank support. Jacurutu counts toward stronghold victory but creates no seventh Stronghold Card. Physical free entry, nested tariffs, trained or native force casualties, card cleanup and optional Tech reward retain their original sources.',
'Great Maker first resolves the ordinary worm consequences. All players then vote in storm order on creating a Nexus. A strict majority is required; a tie adds no Nexus and an existing Nexus in the phase stays recorded. Fremen may then ride with actual reserve forces to one legal destination for free, without using ordinary shipment or movement. Storm and occupancy restrictions still apply.',
'Great Maker first resolves the ordinary worm consequences. All players then vote in storm order on creating a Nexus. A strict majority is required; a tie adds no Nexus and an existing Nexus in the phase stays recorded. Unless this turn’s predeclared Fremen Nexus worm-riding prohibition applies, Fremen may then ride with actual reserve forces to one legal destination for free, without using ordinary shipment or movement. Storm and occupancy restrictions still apply.',
'Each of the six Discovery territory cards destroys prior spice and forces in the named blow territory, including Fremen forces, before placing six new spice subject to the storm. It also places a random available face-down token of the printed type at the card’s separate token destination.',
'Fremen privately see placed Hiereg tokens; Guild privately sees placed Smuggler tokens. During Spice Collection, non-advisor forces in a token’s surrounding territory may inspect it privately, then choose whether to reveal it. The Discovery panel shows only authorized faces. Reading an already visible token’s rules does not reveal or use it.',
'Spice Stash grants seven bank spice and leaves play. Treachery Card Stash draws one private Treachery Card and leaves play; if the new hand is too large, choose any held card to discard, including the newly drawn card. The owned discard panel includes a card inspector.',
@@ -4735,7 +4735,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'During an original positive normal-auction purchase by Emperor’s ally, possible unallied held-Nexus rivals acknowledge in storm order before winner payment, regardless of secret face. Only the current actual Emperor-card holder may Use; Pass keeps cards and original payment. Under the provisional conservative full-price interpretation, native Emperor covers the full original price, reusing prepaid aid and adding only missing own spice. The buyer keeps their own spice and acquires one original card. Native Emperor income remains separately cancelable without refund; original next-lot inspection continues. No excess gift, bank-created subsidy or future-income borrowing is granted. Underfunded/fully prepaid Use is unavailable; special seller lots and full combinations remain open.',
'With Fremen absent, an actual unallied Fremen-card holder may enable a fixed ordinary free quota of three at a quiet Revival boundary, then use normal partial/typed revival controls. Activation moves no counters, leader or spice. Actual prior free use counts; paid returns keep their cost and total usage. Native total limits, higher free rates, typed caps, prevention, Homeworld deposits/deployment, Axlotl activity and once-only Tleilaxu income remain original. Recruits doubles its native rate, not this fixed quota. Useless or unknown-ledger grants are unavailable. This is the visibly provisional recommended interpretation; strict old physical receipts adopt metadata without replay. Independent sandworm protection remains the separate operation below.',
'Before one accepted worm devours forces with Fremen absent, publicly possible unallied threatened held-Nexus seats acknowledge in original storm order regardless of secret face. Only the actual Fremen holder may spend to save their own groups. Pass keeps the card; all acknowledgements precede casualties. Under the explicit provisional independent-operation policy, no extra Karama provider or riding is invented. Original other casualties, spice removal, Great Maker vote and next blow/Nexus remain. Protected No-Field uses native protected-victim skipping; wider marker/module acceptance remains open.',
'Native Fremen Cunning has a bounded classic Nexus path, optionally retaining original Tech Tokens and Advanced Stronghold Cards: when a natural, accepted additional, or Advanced special-Karama summoned worm appears in a territory initially containing no forces, Fremen may spend their own Nexus card before the worm resolves. The special summon retains its original Spice Blow parent. After the applicable Nexus, choose some forces from one occupied desert territory and ride to a legal Arrakis destination without using normal movement or changing original component custody/income. The choice appears even without the secret card, so declining does not disclose custody. Karama provisionally prevents only the additional remote ride; the original worm still resolves and the card remains spent. An interrupted existing Cunning control is restored, and a selected ride waits for the summoned Nexus without granting a second Cunning offer; Great Maker, dynamic destinations and other optional-module combinations await integration.',
'Native Fremen Cunning now retains the original registered selected Nexus roster and optional modules: when a natural, accepted additional, or Advanced special-Karama summoned worm appears in a territory initially containing no forces, Fremen may spend their own Nexus card before the worm resolves. The special summon retains its original Spice Blow parent. After the applicable Nexus, choose some forces from one occupied desert territory and ride to a legal Arrakis destination without using normal movement or changing original component custody/income. The choice appears even without the secret card, so declining does not disclose custody. Karama provisionally prevents only the additional remote ride; the original worm still resolves and the card remains spent. An interrupted existing Cunning control is restored, and a selected ride waits for the summoned Nexus without granting a second Cunning offer; Revealed Discovery destinations and source-parent HMS entry use the shared legal quote. A living native Sandmaster may optionally collect one spice at the destination. Great Maker replaces ordinary board-origin riding; its own encounter adds no Cunning board ride, and a completed frame does not block a later ordinary appearance. Complete combinations remain open.',
'CHOAM Secret Ally has a working Collection trade: when CHOAM is absent, an unallied holder may discard one Worthless card and the Nexus card for two bank spice. After a battle victory, every winner in an eligible Nexus game receives the same use-or-continue window; an unallied CHOAM-card holder may instead spend that card to inspect one random unused card in the opposing combatant’s current hand. Played battle cards remain excluded even if retained. The inspected face is private, while use is public. The universal window protects concealed card custody; this is an interface interpretation, not a publisher-prescribed prompt. An ordinary Karama response to this Secret Ally inspection remains unestablished.',
'With CHOAM seated, an unallied holder may spend CHOAM Betrayal at a clean play boundary to discard one uniformly random held CHOAM Treachery Card without spice compensation. The target name is public, but its hand count is offered only during Bidding; the holder cannot pick or inspect the card before the ordinary discard reveal.',
'CHOAM Cunning spends its Nexus card to use one held Treachery Card for one chosen Worthless effect at that effect’s normal timing, in Basic and Advanced. Choose the physical card separately from the effect. Kulon, La La La, Baliset, Jubba Cloak and Trip to Gamont retain their existing runtime. The explicit new Nexus Kull profile adds Kull at the canonical Karama reaction only; it remains unavailable in other Nexus previews. The old printed Kull profile still excludes Nexus. See the source-aware Kull guide for cost custody, counters and pending verification.',
@@ -4746,7 +4746,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'With actual Advanced Stronghold Cards, Arrakeen bank support changes the real payer debit, not personal income. Emperor Cunning temporary Sardaukar remain physically ordinary counters and retain ordinary Tanks custody. Original losing Moritani retention precedes mandatory original-winner Tech, then CHOAM inspection excludes used opposing cards even if retained. Ordinary city Collection remains separate; none of these windows repeats the original support, card cost or casualties.',
'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. Original native and selected-independent Nexus profiles retain World, training and phase modules. Advisors return as native counters; a concealed No-Field provisionally returns face down without materializing counters. The full unspent opening remains available after an actual Guild first/wait or Sapho reorder.',
'Ecaz Cunning lets an unallied native holder spend its physical Nexus at an original quiet boundary in any phase to take the existing Duke for this turn, including living control, original captured custody or the set-aside disc in Tanks. Finish committed auctions, Battle, responses, Ambassador and other physical continuations first; no chooser or phase is stolen. New version3 records bind actual phase and custody; legacy ordinary/version2 Battle history remains strict. Tanks preserve deaths and independently clear old use without spice or ordinary allowance; living already-used Duke gains no fresh use. Standalone no-Skills Ecaz/Advanced Harkonnen connects original capture and provisional permanent liberation; one battle or unused current-turn expiry sets Duke aside, never back to the captor. Actual Ecaz Ghola returns set aside before separate Cunning; foreign-Ghola revival remains forbidden. Roster/module and shared-training guards remain.',
'Fremen Betrayal has two bounded advance-declaration alternatives for an unallied holder in a classic Nexus table. Before the first Spice Blow or worm appearance, spend the physical card to prevent Fremen ordinary and Cunning worm rides for the turn; worm survival, destruction and Nexus still resolve. This clean timing is a product boundary, not an official post-appearance priority ruling. Instead, before Fremen move during Shipment and Movement, spend it to suppress their native two-territory advantage for that turn while ordinary movement and independent ornithopters remain. Only one alternative can be used even if the card is recycled later that turn. Owner controls show the currently available choice; combined modules and reactive post-worm timing remain gated.',
'Fremen Betrayal has two advance-declaration alternatives for an unallied holder in an original registered Nexus table with its selected modules. Before the first Spice Blow or worm appearance, spend the physical card to prevent Fremen ordinary and Cunning worm rides for the turn; worm survival, destruction and Nexus still resolve. This clean timing is a product boundary, not an official post-appearance priority ruling. Instead, before Fremen move during Shipment and Movement, spend it to suppress their native two-territory advantage for that turn while ordinary movement and independent ornithopters remain. Only one alternative can be used even if the card is recycled later that turn. Owner controls show the currently available choice; Great Maker reserve riding is also prevented under the explicit source composition, while its original votes, casualties and Nexus continue. Reactive post-worm timing and complete-combination acceptance remain separate.',
'When Ecaz is absent, an unallied holder may spend its Nexus card at a quiet play boundary to ask whether any one player, including themself, holds one of the holder’s native faction leaders as an ordinary Traitor Card. The owner receives a private yes/no answer saved at the moment of use; no name or count is disclosed. Dead or captured native leaders count, but foreign Gholas, Duke Vidal, Cheap Hero and Face Dancers do not. Native-leader scope and private-only audience are explicit product interpretations, not publisher rulings; exceptional controlled-leader and public-disclosure combinations remain open.',
'When Richese is absent, its Secret Ally replaces your ordinary reserve shipment with up to five physical forces charged as one. Forces, elite identity, destination restrictions, native Homeworld sources and movement remain unchanged. Fremen retain free reinforcement. Native Guild rate cancellation changes the one-counter sand bill from1 to2 without canceling the Nexus source; an already canceled rate is reflected in the quote. Unfunded declared Nexus stays spent without refund/reusable coupon while ordinary shipment remains available under the selected affordability policy. An independently purchased ordinary price remains separate. This is no concealed marker or extra shipment.',
'Native Richese Cunning uses the original signed two-No-Field shipment in supported single/paired E2 or selected mixed Ix+CHOAM native Nexus tables with classics, optional Skills/Homeworlds/Discovery/Tech/Advanced Strongholds. Select two distinct unused physical tokens: one reveals immediately into reserve-capped forces, one remains concealed; pay one marker invoice and spend the Nexus. The previous shipment token cannot be either choice, and the concealed one becomes last-used. Native/Guild response and actual payer/counter sources remain; a nested destination must be revealed. Prevention keeps physical tokens and unpaid spice, after any printed card cost. Sandtrout, arbitrary companions, shared allocation and other pending combinations retain their guards.',
@@ -4761,11 +4761,11 @@ export const RULE_TOPICS: RuleTopic[] = [
],
related: ['ecaz-modules', 'spice-blow', 'alliance-funding', 'choam-worthless', 'choam-kull', 'nexus-choam-kull', 'nexus-richese-betrayal', 'nexus-guild-betrayal', 'nexus-ixian-replacement', 'nexus-ixian-betrayal'],
checklist: [
{area:"Implementation",status:'Partial',detail:"Ecaz Betrayal now composes typed World returns, advisors, actual HMS and provisional face-down marker recall; Guild/Sapho timing preserves a full unspent opening. Native module Atomics retains actual paid arrival and original Cunning desert grants. Selected E1/E2/E3 independent/Nexus now shares fresh setup and original native consumers: typed Ix fees/Harass/substitution, CHOAM fuel, Richese paired invoices, native Occupy/Assassinate, shared arrival order and Duke Cunning. Existing trained-capture and unfinished individual-effect envelopes remain. Native cross/return permission and rate remain separate; Cunning preserves its opportunity. Independent typed purchases and explicit immune Junction source choice retain their original tariffs/payees. Richese Secret Ally now keeps one billed counter and all five physical forces through native Guild rate allowance/cancellation; signed pending/settled sources stay bound. Unfunded release retains spent Nexus without debt/refund/reusable coupon and preserves ordinary shipment. Marker approvals still cannot silently increase; full acceptance remains open.",evidence:['tests/e3-independent-nexus-occupy.test.ts','tests/fixture-e3-independent-nexus-occupy.ts','tests/e3-independent-nexus-cards.test.ts','tests/fixture-e3-independent-nexus-cards.ts','tests/e3-independent-nexus-duke.test.ts','tests/fixture-e3-independent-nexus-duke.ts','tests/e3-independent-nexus-arrival.test.ts','tests/fixture-e3-independent-nexus-arrival.ts','game/faction-module-profile.ts','tests/selected-independent-nexus-entry.test.ts','tests/fixture-selected-independent-nexus-entry.ts','tests/selected-independent-nexus-battles.test.ts','tests/fixture-selected-independent-nexus-battles.ts','tests/selected-independent-nexus-cards.test.ts','tests/fixture-selected-independent-nexus-cards.ts','tests/selected-independent-ixian-betrayal.test.ts','game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts', 'game/nexus-borrowed-voice.ts', 'game/combat-response-quote.ts', 'game/karama-context.ts', 'game/nexus-fremen-protection.ts', 'game/admin-seat-ai-shape.ts', 'game/richese-cancellation.ts', 'game/bot-mobility.ts', 'game/planetologist-movement.ts', 'game/revival.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally.ts', 'game/revival-resume.ts', 'game/revival-cancellation.ts', 'game/homeworld-revival-deployment.ts', 'game/homeworld-revival-return.ts', "game/nexus-emperor-purchase.ts", "game/auction-continuation-quote.ts", "game/nexus-emperor-purchase.ts", "game/ambassador-phase.ts", "game/ambassador-resume.ts", "game/battle-aftermath-quote.ts", "game/ecaz-duke-acquisition.ts", "game/ecaz-duke-revival.ts", "game/nexus-ecaz-duke-options.ts", "game/nexus-play-boundary.ts", "game/junction-transport-options.ts"]},
{area:"Player controls",status:'Partial',detail:"Native Betrayal/Atomics table SSR retains typed/concealed return, revelation, ordered arrival, Aftermath and desert controls; CSS stubs mean no visual/browser acceptance. Earlier selected E3 lead/plan, assassination/revival and Duke controls retain their dated proof. Four earlier original-action390px journeys prove native Junction5/full2, poor1/full2 release plus ordinary one-force replacement, still-held source sand2 disabled then five-force stronghold1, and independent purchased1. The response explains the spent-card consequence. No overflow, browser error or application exception; two tool-only stale-node errors were resolved by fresh evidence without replay. Earlier Junction/loan/refund surfaces remain scoped evidence.",evidence:['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx', 'components/battle-preparation.tsx', 'components/nexus-fremen-revival.tsx','components/nexus-emperor-payment.tsx', 'components/nexus-emperor-secret-ally.tsx', "components/nexus-emperor-seller-purchase.tsx", "components/nexus-emperor-ambassador-purchase.tsx", "components/richese-special-karama.tsx", "components/junction-transport.tsx"]},
{area:"AI",status:'Partial',detail:"All four policies retain minimal legal Ecaz Betrayal and original Aftermath/Sneak participation. All four policies execute original selected E3 Duke Cunning and unused expiry, and legal Occupy plans with real World sources. All four original policies also execute a legal still-held Richese full-rate one-counter alternative after native cancellation, with actual physical delivery and card cost. Shared human/AI quotes no longer promise a canceled discount. Native permission alternatives, recipient-specific lender prices and immune Junction source choice remain. Optional non-reoffer is not forced. No strategic first-candidate pin, tuning or calibration.",evidence:['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally-options.ts']},
{area:"Implementation",status:'Partial',detail:"Native Fremen Cunning/Betrayal now retains selected modules, original occurrences, typed rides, optional Sandmaster and source-parent HMS entry. Great Maker prevention and completed-history timing use original continuation. Ecaz Betrayal now composes typed World returns, advisors, actual HMS and provisional face-down marker recall; Guild/Sapho timing preserves a full unspent opening. Native module Atomics retains actual paid arrival and original Cunning desert grants. Selected E1/E2/E3 independent/Nexus now shares fresh setup and original native consumers: typed Ix fees/Harass/substitution, CHOAM fuel, Richese paired invoices, native Occupy/Assassinate, shared arrival order and Duke Cunning. Existing trained-capture and unfinished individual-effect envelopes remain. Native cross/return permission and rate remain separate; Cunning preserves its opportunity. Independent typed purchases and explicit immune Junction source choice retain their original tariffs/payees. Richese Secret Ally now keeps one billed counter and all five physical forces through native Guild rate allowance/cancellation; signed pending/settled sources stay bound. Unfunded release retains spent Nexus without debt/refund/reusable coupon and preserves ordinary shipment. Marker approvals still cannot silently increase; full acceptance remains open.",evidence:['game/fremen-nexus-profile.ts','game/nexus-fremen-cunning.ts','game/nexus-fremen-betrayal.ts','game/sandmaster-worm.ts','tests/e3-independent-nexus-occupy.test.ts','tests/fixture-e3-independent-nexus-occupy.ts','tests/e3-independent-nexus-cards.test.ts','tests/fixture-e3-independent-nexus-cards.ts','tests/e3-independent-nexus-duke.test.ts','tests/fixture-e3-independent-nexus-duke.ts','tests/e3-independent-nexus-arrival.test.ts','tests/fixture-e3-independent-nexus-arrival.ts','game/faction-module-profile.ts','tests/selected-independent-nexus-entry.test.ts','tests/fixture-selected-independent-nexus-entry.ts','tests/selected-independent-nexus-battles.test.ts','tests/fixture-selected-independent-nexus-battles.ts','tests/selected-independent-nexus-cards.test.ts','tests/fixture-selected-independent-nexus-cards.ts','tests/selected-independent-ixian-betrayal.test.ts','game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts', 'game/nexus-borrowed-voice.ts', 'game/combat-response-quote.ts', 'game/karama-context.ts', 'game/nexus-fremen-protection.ts', 'game/admin-seat-ai-shape.ts', 'game/richese-cancellation.ts', 'game/bot-mobility.ts', 'game/planetologist-movement.ts', 'game/revival.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally.ts', 'game/revival-resume.ts', 'game/revival-cancellation.ts', 'game/homeworld-revival-deployment.ts', 'game/homeworld-revival-return.ts', "game/nexus-emperor-purchase.ts", "game/auction-continuation-quote.ts", "game/nexus-emperor-purchase.ts", "game/ambassador-phase.ts", "game/ambassador-resume.ts", "game/battle-aftermath-quote.ts", "game/ecaz-duke-acquisition.ts", "game/ecaz-duke-revival.ts", "game/nexus-ecaz-duke-options.ts", "game/nexus-play-boundary.ts", "game/junction-transport-options.ts"]},
{area:"Player controls",status:'Partial',detail:"Fremen table SSR retains original Cunning spend/ride, Betrayal and Great Maker controls; an actual pending Sandmaster quote also renders its shared checkbox. Native Betrayal/Atomics table SSR retains typed/concealed return, revelation, ordered arrival, Aftermath and desert controls; CSS stubs mean no visual/browser acceptance. Earlier selected E3 lead/plan, assassination/revival and Duke controls retain their dated proof. Four earlier original-action390px journeys prove native Junction5/full2, poor1/full2 release plus ordinary one-force replacement, still-held source sand2 disabled then five-force stronghold1, and independent purchased1. The response explains the spent-card consequence. No overflow, browser error or application exception; two tool-only stale-node errors were resolved by fresh evidence without replay. Earlier Junction/loan/refund surfaces remain scoped evidence.",evidence:['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx', 'components/battle-preparation.tsx', 'components/nexus-fremen-revival.tsx','components/nexus-emperor-payment.tsx', 'components/nexus-emperor-secret-ally.tsx', "components/nexus-emperor-seller-purchase.tsx", "components/nexus-emperor-ambassador-purchase.tsx", "components/richese-special-karama.tsx", "components/junction-transport.tsx"]},
{area:"AI",status:'Partial',detail:"All four policies retain original Fremen Cunning and both Betrayal declarations with shared destination legality. All four policies retain minimal legal Ecaz Betrayal and original Aftermath/Sneak participation. All four policies execute original selected E3 Duke Cunning and unused expiry, and legal Occupy plans with real World sources. All four original policies also execute a legal still-held Richese full-rate one-counter alternative after native cancellation, with actual physical delivery and card cost. Shared human/AI quotes no longer promise a canceled discount. Native permission alternatives, recipient-specific lender prices and immune Junction source choice remain. Optional non-reoffer is not forced. No strategic first-candidate pin, tuning or calibration.",evidence:['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally-options.ts']},
{area:"Documentation",status:'Partial',detail:"Selected E3 scope, printed two-seat no-alliance draw limit and remaining individual-effect envelopes are explicit in the canonical Nexus contract. Richese printed one-counter pricing and native Guild eligibility stay distinct from absent-faction cancellation, physical force count and independent purchased price. Unfunded spent-card/no-refund release is selected existing no-debt composition, not a new publisher worked example. Immune Junction and earlier permission/refund/loan interpretations remain explicit. Full rules and public gates stay open.",evidence:['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/GUILD_RATE_KARAMA_RULES.md', 'docs/RICHESE_ALLIED_SHIPMENT_AUDIT.md', 'docs/GUILD_HOMEWORLD_TRANSPORT_RULES.md', 'docs/NEXUS_GUILD_RULES.md', "docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md", "docs/RULE_DECISIONS.md", "docs/RICHESE_AMBASSADOR_RULES.md", "docs/RICHESE_AMBASSADOR_IMPLEMENTATION.md", "docs/RICHESE_ACQUISITION_RULES.md", "docs/DUKE_VIDAL_RULES.md", "docs/ECAZ_DUKE_ACQUISITION.md", "docs/ECAZ_REVIVAL_RULES.md", "docs/JUNCTION_TRANSPORT_RULES.md", "docs/JUNCTION_TRANSPORT_RUNTIME.md"]},
{area:"Verification",status:'Partial',detail:"Native Betrayal/Atomics composition passes frozen16-file115/115, types/lint/build and eight finite original-action/table-SSR programmes, including original Cunning desert Aftermath; visual/browser and deployed acceptance remain open. Earlier selected E3 entry, Occupy, Assassinate/Recruits, Duke and arrival proof remains dated in the canonical contract. Earlier selected E1/E2 native card/cancellation proof remains dated in the canonical Nexus contract. Classic core retains original World/Tech/Stronghold modules with base33/selected36 and optional14. Independent World cases use native setup, population-sensitive Recruits, typed foreign Harass and actual own-Kaitain Reinforcements/Cunning. The local fee reduces only its paid temporary roles; original count5 and real stars remain. Earlier phase/card proofs are dated in the canonical contract. Native-family/Discovery/Semuta envelopes, full-mode and comprehensive assurance remain open.",evidence:['tests/ecaz-betrayal-modules.test.ts','tests/fixture-ecaz-betrayal-modules.ts','tests/richese-no-field-hidden-return.test.ts','tests/nexus-ecaz-betrayal-quotes.test.ts','tests/moritani-atomics-modules.test.ts','tests/nexus-moritani-atomics-runtime.test.ts','tests/nexus-world-independent-cards.test.ts','tests/nexus-world-independent-battles.test.ts','tests/reinforcements-temporary-role.test.ts','tests/nexus-core-tech-runtime.test.ts','tests/fixture-nexus-core-tech.ts','tests/nexus-core-strongholds-runtime.test.ts','tests/fixture-nexus-core-strongholds.ts','tests/classic-nexus-core.test.ts','tests/fixture-classic-nexus-core.ts','game/faction-module-profile.ts','tests/classic-independent-nexus-utility.test.ts', 'tests/classic-independent-nexus-battle.test.ts', 'tests/fixture-classic-independent-nexus.ts', 'tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/native-guild-rate-karama.test.ts', 'tests/native-guild-rate-modules.test.ts', 'tests/allied-marker-guild-rate.test.ts', 'tests/purchased-homeworld-rate.test.ts', 'tests/native-guild-transport-permission.test.ts', 'tests/native-guild-homeworld-rate.test.ts', 'tests/native-guild-rate-betrayal.test.ts', 'tests/nexus-guild-cunning-engine.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts', 'tests/nexus-borrowed-voice.test.ts', 'tests/fixture-nexus-borrowed-voice.ts', 'tests/nexus-borrowed-voice-runtime.test.ts', 'tests/nexus-fremen-protection.test.ts', 'tests/fixture-nexus-fremen-protection.ts', 'tests/nexus-fremen-proteLine truncated
{area:"Verification",status:'Partial',detail:"Native Fremen modules pass frozen14-file86/86, types/lint/build and nine original-action/rendered-control programmes. HMS has genuine runtime proof; Discovery destination arrival remains quote-only evidence. Earlier native Betrayal/Atomics composition passes frozen16-file115/115, types/lint/build and eight finite original-action/table-SSR programmes, including original Cunning desert Aftermath; visual/browser and deployed acceptance remain open. Earlier selected E3 entry, Occupy, Assassinate/Recruits, Duke and arrival proof remains dated in the canonical contract. Earlier selected E1/E2 native card/cancellation proof remains dated in the canonical Nexus contract. Classic core retains original World/Tech/Stronghold modules with base33/selected36 and optional14. Independent World cases use native setup, population-sensitive Recruits, typed foreign Harass and actual own-Kaitain Reinforcements/Cunning. The local fee reduces only its paid temporary roles; original count5 and real stars remain. Earlier phase/card proofs are dated in the canonical contract. Native-family/Discovery/Semuta envelopes, full-mode and comprehensive assurance remain open.",evidence:['tests/fremen-cunning-modules.test.ts','tests/fixture-fremen-cunning-modules.ts','tests/fremen-betrayal-modules.test.ts','tests/fixture-fremen-betrayal-modules.ts','tests/nexus-fremen-cunning-modules-quotes.test.ts','tests/ecaz-betrayal-modules.test.ts','tests/fixture-ecaz-betrayal-modules.ts','tests/richese-no-field-hidden-return.test.ts','tests/nexus-ecaz-betrayal-quotes.test.ts','tests/moritani-atomics-modules.test.ts','tests/nexus-moritani-atomics-runtime.test.ts','tests/nexus-world-independent-cards.test.ts','tests/nexus-world-independent-battles.test.ts','tests/reinforcements-temporary-role.test.ts','tests/nexus-core-tech-runtime.test.ts','tests/fixture-nexus-core-tech.ts','tests/nexus-core-strongholds-runtime.test.ts','tests/fixture-nexus-core-strongholds.ts','tests/classic-nexus-core.test.ts','tests/fixture-classic-nexus-core.ts','game/faction-module-profile.ts','tests/classic-independent-nexus-utility.test.ts', 'tests/classic-independent-nexus-battle.test.ts', 'tests/fixture-classic-independent-nexus.ts', 'tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/native-guild-rate-karama.test.ts', 'tests/native-guild-rate-modules.test.ts', 'tests/allied-marker-guild-rate.test.ts', 'tests/purchased-homeworld-rate.test.ts', 'tests/native-guild-transport-permission.test.ts', 'tests/native-guild-homeworld-rate.test.ts', 'tests/native-guild-rate-betrayal.test.ts', 'tests/nexus-guild-cunning-engine.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skiLine truncated
],
},
{
+23
View File
@@ -1,4 +1,5 @@
import type { Game, GameView } from './engine';
import { TERRITORIES, splitLocation } from './board';
import { faction } from './catalog';
import { nativeExpansionLeaderSkillsProfile } from './leader-skill-profile';
import { sandmasterDestinationCollection, sandmasterLeader, sandmasterModeSupported,
@@ -25,3 +26,25 @@ export function sandmasterWormCollection(
return blocked('Sandmaster worm collection with other optional modules is still being integrated.');
return sandmasterDestinationCollection(game, player, decision.territory, destination, sector, selectedPile);
}
/** Cunning is still a worm ride; its selected source is the remote desert,
* rather than the territory of the already resolved empty appearance. */
export function sandmasterCunningCollection(
game: Game | GameView,
player: string,
source: string,
destination: string,
sector: number,
selectedPile?: string,
decision: Game['decision'] | GameView['decision'] = game.decision,
): SandmasterDestinationCollection | null {
const leader = sandmasterLeader(game, player);
const rider = game.players.find(p => p.id === player);
if (!leader || game.phase !== 1 || decision?.kind !== 'nexusFremenCunningRide' ||
decision.player !== player || rider?.faction !== 'fremen') return null;
if (!TERRITORIES.some(t => t.id === source && t.type === 'sand') ||
!Object.entries(rider.forces).some(([key, amount]) => amount > 0 &&
splitLocation(key).territory === source && splitLocation(key).sector !== game.storm))
return { leader, key: null, before: 0, piles: [], blocked: 'Choose an occupied desert source for this remote ride.' };
return sandmasterDestinationCollection(game, player, source, destination, sector, selectedPile);
}
+247
View File
@@ -0,0 +1,247 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { distance, location, splitLocation, TERRITORIES, territory } from '../game/board';
import { nativeShipmentSources } from '../game/homeworld-options';
import { greatMakerRideAction } from '../game/great-maker-options';
import {
actE3IndependentDuke, e3IndependentDukeClean, nextE3IndependentDukeStep,
} from './fixture-e3-independent-nexus-duke';
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
export type FremenBetrayalModulesStep = { actor: string; action: Action };
export type FremenBetrayalModulesOptions = {
advanced?: boolean;
homeworlds?: boolean;
skills?: boolean;
tech?: boolean;
strongholds?: boolean;
expansions?: Game['expansions'];
program?: 'movement' | 'worm';
/** Earn city ornithopters through a real free reinforcement and board move. */
city?: boolean;
/** The real Nexus between Advanced piles may end the native alliance before
* an empty second-pile appearance after the completed Great Maker. */
emptyAfterMaker?: boolean;
};
export const fremenBetrayalModulesClean = e3IndependentDukeClean;
export const fremenBetrayalModulesPlayer = (game: Game, actor: string) => {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
};
export const actFremenBetrayalModules = actE3IndependentDuke;
export function advanceFremenBetrayalModules(game: Game, until: (state: Game) => boolean,
actions: FremenBetrayalModulesStep[] = []): Game {
for (let count = 0; count < 2200; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original Fremen programme ended before its owned boundary');
const step = nextE3IndependentDukeStep(game);
assert.ok(step, `Complete the real ${game.decision?.kind ?? 'Battle Plan'} at ${game.turn}/${game.phase}`);
game = actFremenBetrayalModules(game, step.actor, step.action, actions);
}
throw Error('The original Fremen Betrayal programme did not reach its finite boundary');
}
/** Original lobby selections, original private setup choices and original
* typed starting placement. The scalar producer only orders unopened decks. */
function originalSetup(options: FremenBetrayalModulesOptions) {
const discovery = options.program === 'worm';
let game = createGame('FREMENBETRAYALMODULES', newPlayer('fremen', 'Fremen', 'fremen'),
options.advanced ?? true, options.expansions ?? (discovery ? [] : ['ix', 'choam', 'ecaz']));
for (const faction of ['emperor', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
const actions: FremenBetrayalModulesStep[] = [];
const act = (actor: string, action: Action) => { game = actFremenBetrayalModules(game, actor, action, actions); };
for (const selection of ['nexusCards', ...(discovery ? ['discoveries'] : ['ecazTreachery']),
...(options.skills !== false ? ['leaderSkills'] : [])])
act(game.host, { type: 'moduleOption', module: selection, enabled: true });
for (const [type, enabled] of [['homeworlds', options.homeworlds ?? true], ['techTokens', options.tech ?? true],
['strongholdCards', options.strongholds ?? game.advanced]] as const)
if (enabled) act(game.host, { type, enabled: true });
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
act(game.host, game.advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
const setup = structuredClone(game);
const source = location('sietch_tabr', territory('sietch_tabr').sectors[0]);
for (let count = 0; game.status === 'setup' && count < 180; count++) {
let step: FremenBetrayalModulesStep | undefined;
if (game.setupStage === 'forces' && fremenBetrayalModulesPlayer(game, 'fremen').reserves === 20)
step = { actor: 'fremen', action: { type: 'fremenSetup', placements: { [source]: 10 },
...(game.advanced ? { elitePlacements: { [source]: 1 } } : {}) } };
else for (const player of game.players) {
const view = viewGame(game, player.id);
view.players.find(seat => seat.id === player.id)!.bot = 'Easy';
const action = botActions(view)[0];
if (action) { step = { actor: player.id, action }; break; }
}
assert.ok(step, 'Original private Fremen setup must expose a legal next control');
act(step.actor, step.action);
}
assert.equal(game.status, 'playing');
return { lobby, setup, game, actions, source };
}
/** Choose a position only while each physical Spice Card remains unopened. */
function orderSpice(game: Game, choices: string[]) {
const front: Game['spiceDeck'] = [];
for (const choice of choices) {
const at = game.spiceDeck.findIndex(card => choice === 'worm'
? 'worm' in card && !card.greatMaker && !card.thumper && !card.suppressed
: choice === 'maker' ? 'worm' in card && !!card.greatMaker
: 'territory' in card && !card.discovery && card.territory === choice);
assert.ok(at >= 0, `The original ${choice} Spice Card must remain unopened`);
front.push(game.spiceDeck.splice(at, 1)[0]);
}
game.spiceDeck.unshift(...front);
}
/** Genuine first setup, turn-two worm, reciprocal alliance and closing draw.
* Movement keeps its original board source. Worm mode uses two actual paid/
* free typed shipments before the next pre-blow window; no played state edits. */
export function createFremenBetrayalModulesFixture(options: FremenBetrayalModulesOptions = {}) {
const program = options.program ?? 'movement';
const source = withClassicDiscoveryNexusLottery(() => originalSetup({ ...options, program }));
let game = structuredClone(source.game);
const actions = [...source.actions], afterSetup = structuredClone(game);
const act = (actor: string, action: Action) => { game = actFremenBetrayalModules(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceFremenBetrayalModules(game, until, actions); };
const shipments: { before: Game; step: FremenBetrayalModulesStep; after: Game }[] = [];
const ship = (actor: string, to: string, sector: number, amount: number, elite: number) => {
const sources = game.homeworlds ? nativeShipmentSources(viewGame(game, actor), amount, elite) : undefined;
assert.ok(!game.homeworlds || sources);
const step: FremenBetrayalModulesStep = { actor, action: { type: 'ship', territory: to, sector,
amount, elite, allyPayment: 0, ...(sources ? { homeworldSources: sources } : {}) } };
const before = structuredClone(game); act(actor, step.action); reach(fremenBetrayalModulesClean);
shipments.push({ before, step, after: structuredClone(game) });
};
orderSpice(game, ['broken_land', ...(game.advanced ? ['south_mesa'] : [])]);
let cityMove: FremenBetrayalModulesStep | undefined;
if (options.city) {
reach(state => state.phase === 5 && state.active === 'fremen' && fremenBetrayalModulesClean(state));
ship('fremen', 'hagga_basin', 12, 1, 0);
cityMove = { actor: 'fremen', action: { type: 'move', from: 'hagga_basin:12', amount: 1,
elite: 0, territory: 'carthag', sector: 11 } };
act(cityMove.actor, cityMove.action); reach(fremenBetrayalModulesClean);
}
reach(state => state.turn === 2 && state.phase === 1 && fremenBetrayalModulesClean(state));
const afterFirstTurn = structuredClone(game);
orderSpice(game, ['worm', 'red_chasm', ...(game.advanced
? [options.emptyAfterMaker ? 'sihaya_ridge' : 'hagga_basin'] : [])]);
reach(state => state.nexus && !state.spiceWindow && !state.spiceResolution && fremenBetrayalModulesClean(state));
const beforeAlliance = structuredClone(game);
act('fremen', { type: 'alliance', target: 'emperor' }); act('emperor', { type: 'alliance', target: 'fremen' });
reach(state => state.nexusCards?.phase?.stage === 'drawing' && fremenBetrayalModulesClean(state));
const cards = game.nexusCards!.cards!, at = cards.deck.indexOf('fremen'); assert.ok(at >= 0);
cards.deck.unshift(cards.deck.splice(at, 1)[0]);
const beforeDraw = structuredClone(game), draw: FremenBetrayalModulesStep = { actor: 'guild', action: {
type: 'nexusCardChoice', turn: game.turn, card: cards.hands.guild, choice: 'draw', ownRedraws: 0,
} };
act(draw.actor, draw.action);
const afterDraw = structuredClone(game);
if (program === 'worm') {
assert.ok(game.advanced, 'This bounded Great Maker programme uses both real Advanced spice piles');
const shipped = new Set<string>();
for (let count = 0; shipped.size < 2 && count < 500; count++) {
if (game.phase === 5 && fremenBetrayalModulesClean(game) && game.active &&
['guild', 'fremen'].includes(game.active) && !shipped.has(game.active)) {
const actor = game.active;
if (actor === 'guild') ship(actor, 'red_chasm', 7, 1, 0);
else ship(actor, 'hagga_basin', 12, 2, 1);
shipped.add(actor);
} else {
const step = nextE3IndependentDukeStep(game); assert.ok(step);
act(step.actor, step.action);
}
}
assert.equal(shipped.size, 2);
reach(state => state.turn === 3 && state.phase === 1 && fremenBetrayalModulesClean(state));
orderSpice(game, ['maker', 'habbanya_erg', 'worm', 'oh_gap']);
} else reach(state => state.phase === 5 && state.active === 'fremen' && fremenBetrayalModulesClean(state));
const offer = viewGame(game, 'guild').nexusFremenBetrayal;
assert.ok(offer && !offer.blocked, offer?.blocked ?? 'The genuine card needs its original advance-declaration window');
assert.equal(offer.mode, program === 'worm' ? 'worm' : 'movement');
const play: Action = { type: 'nexusFremenBetrayal', event: offer.event };
return { ...source, game, program, actions, afterSetup, afterFirstTurn, beforeAlliance, beforeDraw, draw,
afterDraw, shipments, cityMove, offer, play, emptyAfterMaker: options.emptyAfterMaker ?? false,
owner: 'guild', rider: 'fremen', victim: 'guild',
staging: ['Only original undealt Spice/Nexus positions and scalar setup lotteries are selected; all setup, alliances, native source-qualified shipments and moves are actual actions.'] };
}
export type FremenBetrayalModulesFixture = ReturnType<typeof createFremenBetrayalModulesFixture>;
/** An empty printed destination at the requested actual board distance. */
export function fremenBetrayalModulesMove(game: Game, source: string, range: number): Action {
const candidate = TERRITORIES.filter(row => row.type === 'sand' || row.type === 'rock')
.flatMap(row => row.sectors.map(sector => location(row.id, sector)))
.find(to => game.players.every(player => !Object.entries(player.forces).some(([key, count]) =>
count > 0 && splitLocation(key).territory === splitLocation(to).territory)) &&
distance(source, to, key => splitLocation(key).sector === game.storm) === range);
assert.ok(candidate, `An empty printed range-${range} destination must exist from the original source`);
const to = splitLocation(candidate), own = fremenBetrayalModulesPlayer(game, 'fremen');
return { type: 'move', from: source, amount: 1, elite: (own.elites?.forces[source] ?? 0) > 0 ? 1 : 0,
territory: to.territory, sector: to.sector };
}
/** Complete the actual restricted move and the remainder of its native turn. */
export function finishFremenBetrayalMovement(fixture: FremenBetrayalModulesFixture, range = 1) {
const actions: FremenBetrayalModulesStep[] = [];
let game = actFremenBetrayalModules(structuredClone(fixture.game), fixture.owner, fixture.play, actions);
const spent = structuredClone(game), move = fremenBetrayalModulesMove(game, fixture.source, range);
game = actFremenBetrayalModules(game, fixture.rider, move, actions);
game = advanceFremenBetrayalModules(game, fremenBetrayalModulesClean, actions);
const moved = structuredClone(game);
game = advanceFremenBetrayalModules(game, state => state.turn > fixture.game.turn &&
state.phase === 1 && fremenBetrayalModulesClean(state), actions);
return { game, spent, moved, move, actions };
}
/** Real Great Maker votes, native reserve ride in the unspent control, both
* replacement blows and the ordinary typed native survivor. No board ride is
* added to the Great Maker and no legal action is synthesized from a save. */
export function finishFremenBetrayalWorm(fixture: FremenBetrayalModulesFixture, spend = true) {
assert.equal(fixture.program, 'worm');
const actions: FremenBetrayalModulesStep[] = [], votes: FremenBetrayalModulesStep[] = [],
rides: { before: Game; action: Action; after: Game }[] = [], ordinaryRides: FremenBetrayalModulesStep[] = [],
cunningOffers: Game[] = [];
let game = structuredClone(fixture.game);
if (spend) game = actFremenBetrayalModules(game, fixture.owner, fixture.play, actions);
const spent = structuredClone(game);
for (let count = 0; count < 650; count++) {
if (game.phase === 2 && fremenBetrayalModulesClean(game))
return { game, spent, actions, votes, rides, ordinaryRides, cunningOffers };
const before = structuredClone(game), decision = game.decision;
let step: FremenBetrayalModulesStep | null;
if (fixture.emptyAfterMaker && game.nexus && !game.spiceWindow && !game.spiceResolution &&
fremenBetrayalModulesClean(game) && fremenBetrayalModulesPlayer(game, fixture.rider).ally) {
step = { actor: fixture.rider, action: { type: 'alliance', target: null } };
} else if (!game.response && !game.phaseOpening && decision?.kind === 'nexusFremenCunningOffer') {
cunningOffers.push(structuredClone(game));
step = { actor: decision.player, action: { type: 'decision', event: decision.event, accept: false } };
} else if (!game.response && !game.phaseOpening && decision?.kind === 'greatMakerVote') {
step = { actor: decision.player, action: { type: 'decision', event: decision.event, yes: true } };
votes.push(structuredClone(step));
} else if (!game.response && !game.phaseOpening && decision?.kind === 'greatMakerRide') {
const action = greatMakerRideAction(viewGame(game, fixture.rider), 'polar_sink', 0, 1, game.advanced ? 1 : 0);
assert.ok(action, 'The unspent control uses the original typed reserve-ride control');
step = { actor: fixture.rider, action };
} else step = nextE3IndependentDukeStep(game);
assert.ok(step, 'The native Great Maker continuation must keep its own legal next choice');
if (decision?.kind === 'wormRide') ordinaryRides.push(structuredClone(step));
game = actFremenBetrayalModules(game, step.actor, step.action, actions);
if (decision?.kind === 'greatMakerRide') rides.push({ before, action: step.action, after: structuredClone(game) });
}
throw Error('The original Great Maker/worm sequence did not reach Bidding preparation');
}
/** Four finite legal declarations, without difficulty-strength calibration. */
export function smokeFremenBetrayalModules(options: FremenBetrayalModulesOptions = {}) {
const fixture = createFremenBetrayalModulesFixture(options);
return (['Easy', 'Medium', 'Hard', 'Brutal'] as const).map(difficulty => {
const view = viewGame(fixture.game, fixture.owner);
view.players.find(player => player.id === fixture.owner)!.bot = difficulty;
const action = botActions(view).find(action => action.type === 'nexusFremenBetrayal'); assert.ok(action, difficulty);
const game = actFremenBetrayalModules(structuredClone(fixture.game), fixture.owner, action);
const native = fremenBetrayalModulesPlayer(game, fixture.rider);
return { difficulty, phase: game.phase, mode: fixture.offer.mode,
restricted: fixture.offer.mode === 'worm' ? native.fremenNexusWormBlockedTurn === game.turn
: native.fremenNexusMovementBlockedTurn === game.turn,
spent: game.nexusCards!.cards!.discard.filter(card => card === 'fremen').length };
});
}
+261
View File
@@ -0,0 +1,261 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import type { FactionId } from '../game/catalog';
import { territory } from '../game/board';
import { nativeShipmentSources } from '../game/homeworld-options';
import { sandmasterCunningCollection } from '../game/sandmaster-worm';
import type { LeaderSkillId } from '../game/leader-skill-cards';
import { skillOfferRolls } from './fixture-independent-card-modules';
import { nextPairedChoamSkillsStep } from './fixture-paired-choam-nexus-skills';
export type FremenCunningModulesStep = { actor: string; action: Action };
export type FremenCunningModulesOptions = {
advanced?: boolean;
expansions?: Game['expansions'];
factions?: readonly FactionId[];
skills?: boolean;
homeworlds?: boolean;
tech?: boolean;
strongholds?: boolean;
hmsAtSource?: boolean;
};
export const fremenCunningModulesPlayer = (game: Game, actor: string) => {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
};
export const fremenCunningModulesClean = (game: Game) => !game.phaseOpening && !game.response &&
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingGuildBetrayal &&
!game.pendingNexusIxianReplacement && !game.pendingNexusIxianBetrayal && !game.pendingNexusHarkonnenBetrayal;
/** Scope the real undealt skill lottery; all remaining scalar shuffles retain
* their original order. UUIDs and all other random byte arrays stay native. */
function originalLottery<T>(rolls: readonly number[], operation: () => T): T {
const native = crypto.getRandomValues.bind(crypto), descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[cursor++] ?? 0xffffffff;
else Reflect.apply(native, crypto, [array]);
return array;
};
try { return operation(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
export function actFremenCunningModules(game: Game, actor: string, action: Action, actions?: FremenCunningModulesStep[]): Game {
actions?.push({ actor, action: structuredClone(action) });
return originalLottery([], () => applyAction(game, actor, action));
}
/** Original no-spend/no-movement progression stops at owned Terror, Ambassador
* and the remote ride. The real first empty appearance has no held Fremen card
* and is declined; no private Nexus entitlement is manufactured for it. */
export function nextFremenCunningModulesStep(game: Game): FremenCunningModulesStep | null {
if (game.pendingGuildBetrayal) {
const frame = game.pendingGuildBetrayal, actor = frame.required.find(actor => !frame.passed.includes(actor)); assert.ok(actor);
return { actor, action: { type: 'guildBetrayalPass', event: frame.invoice.event } };
}
if (game.pendingNexusIxianReplacement) return { actor: game.pendingNexusIxianReplacement.buyer,
action: { type: 'nexusIxianReplacementPass', event: game.pendingNexusIxianReplacement.event } };
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
const decision = game.decision;
if (decision?.kind === 'nexusFremenCunningRide' || decision?.kind === 'moritaniTerror' || decision?.kind === 'ecazAmbassador') return null;
if (decision?.kind === 'nexusFremenCunningOffer') return { actor: decision.player,
action: { type: 'decision', event: decision.event, accept: false } };
if (decision?.kind === 'homeworldShipmentGuild') return { actor: decision.player,
action: { type: 'decision', event: decision.event, allow: true } };
if (decision?.kind === 'homeworldDefense') return { actor: decision.player,
action: { type: 'decision', event: decision.event, use: false } };
if (decision?.kind === 'moritaniPlacement' || decision?.kind === 'ecazPlacement' ||
decision?.kind === 'homeworldRevivalDeployment' || decision?.kind === 'caladanReinforcement' || decision?.kind === 'grummanCollection')
return { actor: decision.player, action: { type: 'decision', event: 'event' in decision ? decision.event : undefined, decline: true } };
if (decision?.kind === 'guildTiming') return { actor: decision.player, action: { type: 'decision', take: false } };
if (decision?.kind === 'wormRide' || decision?.kind === 'wormPlacement' || decision?.kind === 'advisor' ||
decision?.kind === 'intrusion' || decision?.kind === 'advisorBattle')
return { actor: decision.player, action: { type: 'decision', accept: false } };
if (decision?.kind === 'leaderSkillVisibility') return { actor: decision.player,
action: { type: 'leaderSkillVisibility', event: decision.event, hide: false } };
if (decision?.kind === 'mentatQuestion') return { actor: decision.player,
action: { type: 'decision', event: decision.event, decline: true } };
if (decision?.kind === 'moritaniSetup') return { actor: decision.player,
action: { type: 'decision', territory: 'pasty_mesa', sector: 5 } };
}
return nextPairedChoamSkillsStep(game);
}
export function advanceFremenCunningModules(game: Game, until: (state: Game) => boolean,
actions: FremenCunningModulesStep[] = []): Game {
for (let count = 0; count < 2400; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The genuine Fremen Cunning chronology ended early');
const next = nextFremenCunningModulesStep(game);
assert.ok(next, `Choose the original ${game.decision?.kind ?? 'Battle Plan'} at ${game.turn}/${game.phase}`);
game = actFremenCunningModules(game, next.actor, next.action, actions);
}
throw Error('The genuine Fremen Cunning chronology did not reach its boundary');
}
/** Move original unopened physical Spice Cards only. Never assign a discard
* target, board pile, appearance identity or historical worm record. */
function orderOriginalSpice(game: Game, entries: readonly string[]): void {
const front: Game['spiceDeck'] = [];
for (const entry of entries) {
const index = game.spiceDeck.findIndex(card => entry === 'worm'
? 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper
: 'territory' in card && card.territory === entry && !card.discovery);
assert.ok(index >= 0, `Use original unopened Spice Card ${entry}`);
front.push(game.spiceDeck.splice(index, 1)[0]);
}
game.spiceDeck.unshift(...front);
}
const quietTraining: Partial<Record<FactionId, LeaderSkillId>> = {
fremen: 'sandmaster', guild: 'killer-medic', emperor: 'master-of-assassins',
ixians: 'prana-bindu-adept', choam: 'swordmaster-of-ginaz', ecaz: 'warmaster',
};
/** Ordinary host module selections and private setup choices. The all14 case
* chooses a genuinely offered Sandmaster and eligible living Fremen trainer.
* Only unopened lotteries are ordered; no post-deal inventory is replaced. */
export function createFremenCunningModulesFixture(options: FremenCunningModulesOptions = {}) {
const factions = options.factions ?? ['fremen', 'guild', 'emperor'];
assert.equal(factions[0], 'fremen');
assert.ok(factions.includes('guild') && factions.includes('emperor'));
let game = createGame('FREMENCUNNINGMODULES', newPlayer('fremen', 'Fremen', 'fremen'),
options.advanced ?? false, options.expansions ?? ['ecaz']);
for (const faction of factions.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
const initial = structuredClone(game), actions: FremenCunningModulesStep[] = [], staging: string[] = [];
const act = (actor: string, action: Action) => { game = actFremenCunningModules(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceFremenCunningModules(game, until, actions); };
for (const selection of ['ecazTreachery', 'nexusCards', ...(options.skills ? ['leaderSkills'] : [])])
act(game.host, { type: 'moduleOption', module: selection, enabled: true });
for (const [type, enabled] of [['homeworlds', options.homeworlds], ['techTokens', options.tech], ['strongholdCards', options.strongholds]] as const)
if (enabled) act(game.host, { type, enabled: true });
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game), start: FremenCunningModulesStep = { actor: game.host,
action: game.advanced ? { type: 'start', advancedPreview: true } : { type: 'start' } };
actions.push(structuredClone(start));
game = originalLottery(options.skills ? skillOfferRolls(game, quietTraining) : [], () => applyAction(game, start.actor, start.action));
const offered = structuredClone(game);
for (let count = 0; game.status === 'setup' && count < 300; count++) {
if (game.setupStage === 'leaderSkills' && !game.decision) {
const actor = Object.keys(game.leaderSkills!.offers)[0], own = viewGame(game, actor).leaderSkills!;
const wanted = quietTraining[fremenCunningModulesPlayer(game, actor).faction];
const skill = wanted && own.offer!.cards.includes(wanted) ? wanted : own.offer!.cards.find(card => !own.unavailableSkills?.[card]);
const leader = own.eligibleLeaders[0];
assert.ok(skill && !own.unavailableSkills?.[skill] && leader);
act(actor, { type: 'leaderSkill', event: own.offer!.event, skill, leader: leader.id });
continue;
}
if (game.setupStage === 'forces' && fremenCunningModulesPlayer(game, 'fremen').reserves === 20) {
act('fremen', { type: 'fremenSetup', placements: { [`sietch_tabr:${territory('sietch_tabr').sectors[0]}`]: 10 } });
continue;
}
const next = nextFremenCunningModulesStep(game); assert.ok(next); act(next.actor, next.action);
}
assert.equal(game.status, 'playing');
const afterSetup = structuredClone(game);
const trainer = game.leaderSkills?.assignments.find(assignment => assignment.owner === 'fremen' && assignment.skill === 'sandmaster')?.leader ?? null;
assert.ok(!options.skills || trainer);
orderOriginalSpice(game, game.advanced ? ['broken_land', 'rock_outcroppings'] : ['broken_land']);
const counterIndex = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(counterIndex >= 0);
const counterCard = game.deck[counterIndex].id;
game.deck.unshift(game.deck.splice(counterIndex, 1)[0]);
let counterBid: { before: Game; step: FremenCunningModulesStep } | null = null;
let hmsPlacement: { before: Game; step: FremenCunningModulesStep; after: Game } | null = null;
for (let count = 0; count < 700; count++) {
if (fremenCunningModulesPlayer(game, 'guild').hand.some(card => card.id === counterCard)) break;
let next: FremenCunningModulesStep | null;
if (options.hmsAtSource && !game.response && !game.phaseOpening &&
game.decision?.kind === 'mobileStronghold' && game.decision.placement) {
const before = structuredClone(game), step: FremenCunningModulesStep = {
actor: game.decision.player, action: { type: 'decision', location: 'wind_pass:14' } };
act(step.actor, step.action);
hmsPlacement = { before, step, after: structuredClone(game) };
continue;
}
if (!game.response && !game.phaseOpening && game.decision?.kind === 'ixAuction' &&
game.ixAuction?.cards.some(card => card.id === counterCard)) {
const returned = game.ixAuction.cards.find(card => card.id !== counterCard); assert.ok(returned);
act(game.decision.player, { type: 'decision', card: returned.id, position: 'bottom' });
continue;
}
if (fremenCunningModulesClean(game) && game.auction?.cards[game.auction.index]?.id === counterCard) {
next = { actor: game.auction.active, action: game.auction.active === 'guild' && game.auction.bidder !== 'guild'
? { type: 'bid', amount: 1, allyPayment: 0 } : { type: 'passBid' } };
if (next.action.type === 'bid') counterBid = { before: structuredClone(game), step: structuredClone(next) };
} else next = nextFremenCunningModulesStep(game);
assert.ok(next, 'The original paid Karama must pass through its native bank auction');
act(next.actor, next.action);
}
assert.ok(counterBid && fremenCunningModulesPlayer(game, 'guild').hand.some(card => card.id === counterCard));
assert.ok(!options.hmsAtSource || hmsPlacement);
const counterPurchase = { ...counterBid, after: structuredClone(game) };
reach(state => state.turn === 1 && state.phase === 5 && state.active === 'fremen' && fremenCunningModulesClean(state));
const elite = game.advanced ? 2 : 0, homeworldSources = game.homeworlds ? nativeShipmentSources(viewGame(game, 'fremen'), 3, elite) : undefined;
assert.ok(!game.homeworlds || homeworldSources);
const shipmentBefore = structuredClone(game), shipmentStep: FremenCunningModulesStep = { actor: 'fremen', action: {
type: 'ship', territory: 'wind_pass', sector: 14, amount: 3, elite, allyPayment: 0,
...(homeworldSources ? { homeworldSources } : {}) } };
act(shipmentStep.actor, shipmentStep.action); reach(fremenCunningModulesClean);
const shipmentAfter = structuredClone(game);
assert.equal(fremenCunningModulesPlayer(game, 'fremen').forces['wind_pass:14'], 3);
reach(state => state.turn === 2 && state.phase === 1 && fremenCunningModulesClean(state));
orderOriginalSpice(game, game.advanced ? ['worm', 'red_chasm', 'south_mesa'] : ['worm', 'red_chasm']);
reach(state => state.nexus && !state.spiceWindow && !state.spiceResolution && fremenCunningModulesClean(state));
const beforeAlliance = structuredClone(game);
act('guild', { type: 'alliance', target: 'emperor' });
act('emperor', { type: 'alliance', target: 'guild' });
reach(state => state.nexusCards?.phase?.stage === 'drawing' && fremenCunningModulesClean(state));
const deck = game.nexusCards!.cards!.deck, index = deck.indexOf('fremen'); assert.ok(index >= 0);
deck.unshift(deck.splice(index, 1)[0]);
const drawBefore = structuredClone(game), drawStep: FremenCunningModulesStep = { actor: 'fremen', action: {
type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 } };
act(drawStep.actor, drawStep.action);
const drawAfter = structuredClone(game);
reach(state => state.turn === 3 && state.phase === 1 && fremenCunningModulesClean(state));
orderOriginalSpice(game, game.advanced ? ['worm', 'hagga_basin', 'habbanya_erg'] : ['worm', 'hagga_basin']);
let beforeWorm = structuredClone(game), producer: FremenCunningModulesStep | null = null;
for (let count = 0; count < 150; count++) {
if (!game.response && !game.phaseOpening && game.decision?.kind === 'nexusFremenCunningOffer') break;
beforeWorm = structuredClone(game); producer = nextFremenCunningModulesStep(game); assert.ok(producer);
act(producer.actor, producer.action);
}
assert.ok(producer && game.decision?.kind === 'nexusFremenCunningOffer' && game.nexusFremenCunningOffer);
assert.equal(game.nexusFremenCunningOffer.occurrence.territory, 'red_chasm');
staging.push('Original host/module/setup actions, undealt skill and scalar shuffles, actual one-spice Guild Karama purchase, native free typed Fremen reinforcement, real empty worm and Guild/Emperor alliance, actual own Fremen closing draw, then a distinct real empty worm before devouring. Only unopened physical Treachery, Spice and Nexus order is selected.');
return { initial, lobby, offered, afterSetup, game, actions, staging, owner: 'fremen', trainer,
source: 'wind_pass', sourceKey: 'wind_pass:14', destination: 'hagga_basin', destinationSector: 12,
shipment: { before: shipmentBefore, step: shipmentStep, after: shipmentAfter },
counterCard, counterPurchase, hmsPlacement,
beforeAlliance, draw: { before: drawBefore, step: drawStep, after: drawAfter },
beforeWorm, producer, occurrence: structuredClone(game.nexusFremenCunningOffer.occurrence) };
}
export type FremenCunningModulesFixture = ReturnType<typeof createFremenCunningModulesFixture>;
/** Actual spend/response/devouring/Nexus/remote selection, then its native
* arrival continuation. Optional collection debits only the newly earned
* replacement-Blow destination pile before any arrival reaction. */
export function completeFremenCunningModules(fixture: FremenCunningModulesFixture,
options: { collect?: boolean; pile?: string; destination?: { territory: string; sector: number } } = {}) {
const actions = fixture.actions, owner = fixture.owner, event = fixture.occurrence.event;
let game = fixture.game;
const beforeSpend = structuredClone(game), spend: FremenCunningModulesStep = { actor: owner,
action: { type: 'decision', event, accept: true } };
game = actFremenCunningModules(game, owner, spend.action, actions);
const spentResponse = structuredClone(game);
game = advanceFremenCunningModules(game, state => !state.response && !state.phaseOpening && state.decision?.kind === 'nexusFremenCunningRide', actions);
const beforeRide = structuredClone(game), player = fremenCunningModulesPlayer(game, owner);
const total = player.forces[fixture.sourceKey], elite = player.elites?.forces[fixture.sourceKey] ?? 0;
assert.equal(total, 3);
const destination = options.destination ?? { territory: fixture.destination, sector: fixture.destinationSector };
const collection = options.collect ? sandmasterCunningCollection(game, owner, fixture.source,
destination.territory, destination.sector, options.pile) : null;
if (options.collect) assert.ok(collection && !collection.blocked && collection.key);
const ride: FremenCunningModulesStep = { actor: owner, action: { type: 'decision', event, accept: true,
source: fixture.source, forces: { [fixture.sourceKey]: { normal: total - elite, elite } },
territory: destination.territory, sector: destination.sector,
...(options.collect !== undefined ? { sandmasterCollect: options.collect } : {}),
...(options.collect ? { sandmasterPile: collection!.key! } : {}) } };
game = actFremenCunningModules(game, owner, ride.action, actions);
const afterRide = structuredClone(game);
game = advanceFremenCunningModules(game, fremenCunningModulesClean, actions);
return { game, actions, beforeSpend, spend, spentResponse, beforeRide, ride, afterRide, collection };
}
+176
View File
@@ -0,0 +1,176 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { viewGame, type Game } from '../game/engine';
import { location } from '../game/board';
import { homeworldContext } from '../game/homeworld-game';
import { homeworldForceGroups } from '../game/homeworld-custody';
import {
actFremenBetrayalModules, advanceFremenBetrayalModules, createFremenBetrayalModulesFixture,
finishFremenBetrayalMovement, finishFremenBetrayalWorm, fremenBetrayalModulesClean,
fremenBetrayalModulesMove, fremenBetrayalModulesPlayer as player, smokeFremenBetrayalModules,
type FremenBetrayalModulesFixture,
} from './fixture-fremen-betrayal-modules';
function originalCard(f: FremenBetrayalModulesFixture) {
assert.equal(f.beforeDraw.nexusCards!.cards!.hands.guild, null);
assert.equal(f.afterDraw.nexusCards!.cards!.hands.guild, 'fremen');
assert.equal(player(f.afterDraw, 'guild').ally, null);
assert.equal(player(f.afterDraw, 'fremen').ally, 'emperor');
assert.equal(player(f.afterDraw, 'emperor').ally, 'fremen');
assert.ok(f.actions.some(step => step.actor === 'guild' && step.action.type === 'nexusCardChoice'));
}
function spentCard(game: Game, mode: 'worm' | 'movement') {
assert.equal(game.nexusCards!.cards!.hands.guild, null);
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
assert.equal(game.nexusFremenBetrayalHistory!.at(-1)!.mode, mode);
}
function nativePool(game: Game, id: string) {
return homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!)
.find(group => group.id === id)!.forces.fremen;
}
for (const advanced of [false, true]) {
void test(`original ${advanced ? 'Advanced typed' : 'Basic'} selected-family World/training Fremen Betrayal suppresses native range and preserves a real ordinary move`, () => {
const f = createFremenBetrayalModulesFixture({ advanced });
originalCard(f);
assert.deepEqual(f.game.expansions, ['ix', 'choam', 'ecaz']);
assert.ok(f.game.homeworlds && f.game.leaderSkills && f.game.techTokens);
assert.equal(!!f.game.strongholdCards, advanced);
assert.equal(f.game.active, f.rider);
assert.equal(player(f.game, f.rider).moved, 0);
assert.equal(player(f.game, f.rider).forces[f.source], 10);
const long = fremenBetrayalModulesMove(f.game, f.source, 2);
let native = actFremenBetrayalModules(structuredClone(f.game), f.rider, long);
native = advanceFremenBetrayalModules(native, fremenBetrayalModulesClean);
assert.equal(player(native, f.rider).moved, 1, 'The original unspent native movement reaches range two');
const { game, spent, moved, move } = finishFremenBetrayalMovement(f);
spentCard(spent, 'movement');
assert.equal(player(spent, f.rider).fremenNexusMovementBlockedTurn, spent.turn);
assert.deepEqual(player(spent, f.rider).forces, player(f.game, f.rider).forces,
'The advance declaration moves no original board counter');
assert.deepEqual(spent.homeworlds, f.game.homeworlds);
assert.throws(() => actFremenBetrayalModules(spent, f.rider, long), /more than 1 territories away/i);
assert.equal(player(moved, f.rider).moved, 1);
assert.equal(player(moved, f.rider).forces[f.source], 9);
const to = location(String(move.territory), Number(move.sector));
assert.equal(player(moved, f.rider).forces[to], 1);
if (advanced) {
assert.equal(player(moved, f.rider).elites!.forces[f.source] ?? 0, 0);
assert.equal(player(moved, f.rider).elites!.forces[to], 1, 'The actual starting Fedaykin moves as its original starred counter');
}
assert.equal(player(moved, f.rider).reserves, player(f.game, f.rider).reserves);
assert.equal(player(moved, f.rider).spice, player(f.game, f.rider).spice);
assert.equal(moved.pendingFremenMove ?? null, null);
assert.equal(moved.response ?? null, null, 'The ordinary range-one move opens no native advantage cancellation');
assert.equal(game.turn, f.game.turn + 1);
assert.equal(viewGame(game, f.rider).players.find(row => row.id === f.rider)!.fremenNexusMovementBlocked, false);
spentCard(game, 'movement');
});
}
void test('original free Fremen reinforcement and city entry preserve independent ornithopter range under selected-module Betrayal', () => {
const f = createFremenBetrayalModulesFixture({ city: true });
originalCard(f);
assert.equal(f.shipments[0].step.actor, f.rider);
assert.equal(f.shipments[0].step.action.territory, 'hagga_basin');
assert.ok(f.shipments[0].step.action.homeworldSources);
assert.equal(player(f.shipments[0].after, f.rider).reserves,
player(f.shipments[0].before, f.rider).reserves - 1);
assert.equal(player(f.shipments[0].after, f.rider).spice,
player(f.shipments[0].before, f.rider).spice, 'The printed native reinforcement is free');
assert.equal(f.cityMove!.action.from, 'hagga_basin:12');
assert.equal(f.cityMove!.action.territory, 'carthag');
assert.equal(player(f.game, f.rider).forces['carthag:11'], 1);
const { spent, moved, move } = finishFremenBetrayalMovement(f, 3);
spentCard(spent, 'movement');
assert.equal(player(moved, f.rider).forces['carthag:11'], 1);
assert.equal(player(moved, f.rider).forces[location(String(move.territory), Number(move.sector))], 1);
assert.equal(moved.pendingFremenMove ?? null, null, 'City range does not borrow the suppressed native Fremen advantage');
});
void test('original World/training Great Maker votes and ordinary native worm destruction continue while advance Betrayal prevents both ride sources', () => {
const f = createFremenBetrayalModulesFixture({ program: 'worm' });
originalCard(f);
assert.equal(f.game.turn, 3);
assert.equal(f.game.phase, 1);
assert.ok(f.game.discoveries && f.game.homeworlds && f.game.leaderSkills);
assert.equal(player(f.game, f.victim).forces['red_chasm:7'], 1);
assert.equal(player(f.game, f.rider).forces['hagga_basin:12'], 2);
assert.equal(player(f.game, f.rider).elites!.forces['hagga_basin:12'], 1);
const victim = player(f.game, f.victim), rider = player(f.game, f.rider);
const { game, spent, votes, rides, ordinaryRides, cunningOffers } = finishFremenBetrayalWorm(f);
spentCard(spent, 'worm');
assert.equal(player(spent, f.rider).fremenNexusWormBlockedTurn, spent.turn);
assert.deepEqual(player(spent, f.rider).forces, rider.forces);
assert.equal(votes.length, f.game.players.length);
assert.deepEqual(votes.map(step => step.actor), game.greatMaker!.order);
assert.ok(game.greatMaker!.votes.every(vote => vote.yes));
assert.equal(game.greatMaker!.stage, 'complete');
assert.equal(rides.length, 0, 'A current restriction skips the Great Maker reserve-ride control');
assert.equal(ordinaryRides.length, 0, 'The later actual native Hagga worm grants no board ride');
assert.equal(cunningOffers.length, 0);
assert.deepEqual(game.wormRides, []);
assert.equal(player(game, f.victim).forces['red_chasm:7'] ?? 0, 0);
assert.equal(player(game, f.victim).tanks, victim.tanks + 1, 'The genuine paid victim is devoured despite the restriction');
assert.equal(game.spice['red_chasm:7'] ?? 0, 0);
assert.equal(game.spice['hagga_basin:12'] ?? 0, 0);
assert.equal(player(game, f.rider).forces['hagga_basin:12'], 2, 'Native worm survival remains in effect');
assert.equal(player(game, f.rider).elites!.forces['hagga_basin:12'], 1);
assert.equal(player(game, f.rider).reserves, rider.reserves);
assert.equal(player(game, f.rider).elites!.reserves, rider.elites!.reserves);
assert.deepEqual(nativePool(game, 'homeworld:fremen'), nativePool(f.game, 'homeworld:fremen'));
assert.ok(game.spiceDiscard[0].some(card => 'worm' in card && !!card.greatMaker));
assert.ok(game.spiceDiscard[0].some(card => 'territory' in card && card.territory === 'habbanya_erg'));
assert.ok(game.spiceDiscard[1].some(card => 'territory' in card && card.territory === 'oh_gap'));
assert.equal(game.nexusCards!.phase!.occurred, true);
assert.equal(game.nexusCards!.phase!.stage, 'complete');
assert.equal(game.phase, 2, 'Both replacement blows and original Nexus finish before charity');
spentCard(game, 'worm');
});
void test('the same original unspent Great Maker offers and completes a typed native reserve ride before normal worm riding', () => {
const f = createFremenBetrayalModulesFixture({ program: 'worm' });
const { game, rides, ordinaryRides } = finishFremenBetrayalWorm(f, false);
assert.equal(rides.length, 1);
assert.equal(rides[0].before.decision!.kind, 'greatMakerRide');
assert.equal(rides[0].action.amount, 1);
assert.equal(rides[0].action.elite, 1);
const before = player(rides[0].before, f.rider), after = player(rides[0].after, f.rider);
assert.equal(after.reserves, before.reserves - 1);
assert.equal(after.elites!.reserves, before.elites!.reserves - 1);
assert.equal(after.forces['polar_sink:0'], 1);
assert.equal(after.elites!.forces['polar_sink:0'], 1);
assert.equal(after.spice, before.spice);
assert.equal(after.moved, before.moved);
assert.equal(after.shipped, before.shipped);
assert.equal(ordinaryRides.length, 1, 'The distinct occupied Hagga appearance retains its normal later ride control');
assert.equal(game.nexusCards!.cards!.hands.guild, 'fremen');
assert.equal(game.nexusFremenBetrayalHistory?.length ?? 0, 0);
assert.equal(game.phase, 2);
});
void test('a genuine second-pile empty worm offers native Cunning after the Great Maker completes and the real Nexus ends its alliance', () => {
const f = createFremenBetrayalModulesFixture({ program: 'worm', emptyAfterMaker: true });
const { game, cunningOffers, rides, actions } = finishFremenBetrayalWorm(f, false);
assert.equal(rides.length, 1);
assert.equal(cunningOffers.length, 1);
const offered = cunningOffers[0];
assert.equal(offered.greatMaker!.stage, 'complete');
assert.equal(offered.nexusFremenCunningOffer!.occurrence.territory, 'sihaya_ridge');
assert.equal(offered.nexusFremenCunningOffer!.occurrence.initiallyEmpty, true);
assert.equal(player(offered, f.rider).ally, null);
assert.equal(offered.nexusCards!.cards!.hands.guild, 'fremen');
assert.equal(offered.nexusCards!.cards!.hands.fremen, null, 'The public native offer does not disclose or require the private held face');
assert.ok(actions.some(step => step.actor === f.rider && step.action.type === 'alliance' && step.action.target === null));
assert.equal(game.nexusFremenCunningOffer ?? null, null);
assert.equal(game.phase, 2);
});
void test('all four minimal policies retain genuine selected-module movement and Discovery worm declarations', () => {
for (const program of ['movement', 'worm'] as const) {
const report = smokeFremenBetrayalModules({ program });
assert.equal(report.length, 4);
assert.ok(report.every(result => result.restricted && result.spent === 1));
assert.ok(report.every(result => result.mode === program));
}
});
+150
View File
@@ -0,0 +1,150 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { DIFFICULTIES } from '../game/bot-profiles';
import { botActions } from '../game/bots';
import { NEXUS_FACTIONS } from '../game/nexus-cards';
import { presenceAt } from '../game/force-presence';
import { MOBILE_LOCATION, MOBILE_STRONGHOLD } from '../game/board';
import {
createFremenCunningModulesFixture, completeFremenCunningModules,
fremenCunningModulesPlayer as player,
} from './fixture-fremen-cunning-modules';
function physicalNexus(game: Game): void {
const cards = game.nexusCards!.cards!;
assert.deepEqual([...cards.deck, ...cards.discard, ...Object.values(cards.hands).filter(card => card !== null)].sort(), [...NEXUS_FACTIONS].sort());
}
for (const expansions of [['ix'], ['choam']] as const) void test(`Basic selected ${expansions[0]} independent/Nexus uses its actual drawn Fremen Cunning after an empty appearance and real closing Nexus`, () => {
const fixture = createFremenCunningModulesFixture({ expansions: [...expansions] });
assert.ok(fixture.afterSetup.ecazTreachery && fixture.afterSetup.nexusCards);
assert.deepEqual(fixture.afterSetup.expansions, [...expansions]);
assert.equal(fixture.counterPurchase.step.actor, 'guild');
assert.equal(fixture.counterPurchase.step.action.type, 'bid');
assert.equal(player(fixture.counterPurchase.after, 'guild').spice, player(fixture.counterPurchase.before, 'guild').spice - 1);
assert.ok(player(fixture.counterPurchase.after, 'guild').hand.some(card => card.id === fixture.counterCard && card.effect === 'karama'));
assert.equal(player(fixture.shipment.after, 'fremen').reserves, player(fixture.shipment.before, 'fremen').reserves - 3);
assert.equal(player(fixture.shipment.after, 'fremen').spice, player(fixture.shipment.before, 'fremen').spice);
assert.equal(player(fixture.draw.after, 'guild').ally, 'emperor');
assert.equal(player(fixture.draw.after, 'emperor').ally, 'guild');
assert.equal(player(fixture.draw.after, 'fremen').ally, null);
assert.equal(fixture.draw.after.nexusCards!.cards!.hands.fremen, 'fremen');
assert.equal(fixture.occurrence.initiallyEmpty, true);
assert.equal(fixture.occurrence.origin, 'natural');
assert.equal(fixture.occurrence.pile, 0);
assert.equal(fixture.occurrence.turn, 3);
assert.ok(fixture.game.players.every(player => presenceAt(player, fixture.occurrence.territory) === 0));
assert.equal(fixture.game.spice['red_chasm:7'], 8, 'The accepted appearance is captured before the original devouring');
const done = completeFremenCunningModules(fixture), game = done.game;
assert.equal(done.spentResponse.response?.kind, 'nexusFremenCunning');
assert.equal(done.spentResponse.nexusCards!.cards!.hands.fremen, null);
assert.equal(done.spentResponse.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
assert.equal(player(done.spentResponse, 'fremen').forces[fixture.sourceKey], 3);
assert.equal(done.spentResponse.spice['red_chasm:7'], 8);
assert.equal(done.beforeRide.spice['red_chasm:7'] ?? 0, 0);
assert.equal(done.beforeRide.spice['hagga_basin:12'], 6);
assert.equal(done.beforeRide.nexus, true);
assert.deepEqual([...done.beforeRide.ready].sort(), done.beforeRide.players.map(player => player.id).sort());
assert.equal(done.beforeRide.decision?.kind, 'nexusFremenCunningRide');
assert.equal(done.beforeRide.decision?.player, 'fremen');
assert.equal(done.beforeRide.nexusCards?.phase?.stage, 'spice', 'Original closing-card choices follow the post-negotiation rides');
assert.equal(game.nexusFremenCunningRides!.find(ride => ride.occurrence.event === fixture.occurrence.event)!.stage, 'complete');
assert.equal(player(game, 'fremen').forces[fixture.sourceKey] ?? 0, 0);
assert.equal(player(game, 'fremen').forces['hagga_basin:12'], 3);
assert.equal(player(game, 'fremen').moved, player(done.beforeRide, 'fremen').moved);
assert.equal(player(game, 'fremen').reserves, player(done.beforeRide, 'fremen').reserves);
assert.equal(player(game, 'fremen').spice, player(done.beforeRide, 'fremen').spice);
assert.equal(game.spice['hagga_basin:12'], 6);
const canceled = applyAction(done.spentResponse, 'guild', { type: 'card', mode: 'cancel', card: fixture.counterCard });
assert.equal(canceled.nexusFremenCunningRides!.find(ride => ride.occurrence.event === fixture.occurrence.event)!.stage, 'complete');
assert.equal(player(canceled, 'fremen').forces[fixture.sourceKey], 3);
assert.equal(player(canceled, 'fremen').forces['hagga_basin:12'] ?? 0, 0);
assert.equal(canceled.nexusCards!.cards!.hands.fremen, null);
assert.equal(canceled.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
assert.equal(canceled.discard.filter(card => card.id === fixture.counterCard).length, 1);
assert.equal(canceled.spice['red_chasm:7'] ?? 0, 0, 'Canceling the extra ride preserves the original worm destruction');
physicalNexus(game);
});
void test('Advanced registered Ixians/CHOAM/Ecaz with selected independent decks, World/all14/phase retains typed remote riding and genuine Sandmaster collection', () => {
const fixture = createFremenCunningModulesFixture({ advanced: true, expansions: ['ix', 'choam', 'ecaz'],
factions: ['fremen', 'guild', 'emperor', 'ixians', 'choam', 'ecaz'],
skills: true, homeworlds: true, tech: true, strongholds: true });
assert.ok(fixture.afterSetup.ecazTreachery && fixture.afterSetup.nexusCards && fixture.afterSetup.homeworlds &&
fixture.afterSetup.leaderSkills && fixture.afterSetup.techTokens && fixture.afterSetup.strongholdCards);
assert.equal(fixture.afterSetup.players.length, 6);
assert.ok(fixture.trainer);
const assignment = fixture.afterSetup.leaderSkills!.assignments.find(assignment => assignment.owner === 'fremen'); assert.ok(assignment);
assert.equal(assignment.skill, 'sandmaster');
assert.equal(assignment.leader, fixture.trainer);
assert.ok(fixture.actions.some(step => step.actor === 'fremen' && step.action.type === 'leaderSkill' && step.action.skill === 'sandmaster'));
assert.equal(player(fixture.shipment.after, 'fremen').elites!.reserves, player(fixture.shipment.before, 'fremen').elites!.reserves - 2);
assert.equal(player(fixture.game, 'fremen').elites!.forces[fixture.sourceKey], 2);
for (const difficulty of DIFFICULTIES) {
const own = viewGame(fixture.game, 'fremen'); own.players.find(player => player.id === 'fremen')!.bot = difficulty;
const action = botActions(own)[0]; assert.ok(action);
const spent = applyAction(fixture.game, 'fremen', action);
assert.equal(spent.nexusCards!.cards!.hands.fremen, null);
assert.equal(spent.response?.kind, 'nexusFremenCunning');
}
const done = completeFremenCunningModules(fixture, { collect: true });
assert.equal(done.collection!.leader, fixture.trainer);
assert.equal(done.collection!.key, 'hagga_basin:12');
assert.equal(done.collection!.before, 6);
assert.equal(player(done.afterRide, 'fremen').spice, player(done.beforeRide, 'fremen').spice + 1);
assert.equal(done.afterRide.spice['hagga_basin:12'], 5);
assert.equal(player(done.afterRide, 'fremen').forces['hagga_basin:12'], 3);
assert.equal(player(done.afterRide, 'fremen').elites!.forces['hagga_basin:12'], 2);
assert.equal(player(done.afterRide, 'fremen').forces[fixture.sourceKey] ?? 0, 0);
assert.equal(player(done.afterRide, 'fremen').elites!.forces[fixture.sourceKey] ?? 0, 0);
assert.equal(player(done.afterRide, 'fremen').moved, player(done.beforeRide, 'fremen').moved);
assert.equal(player(done.afterRide, 'fremen').shipped, player(done.beforeRide, 'fremen').shipped);
assert.equal(player(done.afterRide, 'fremen').reserves, player(done.beforeRide, 'fremen').reserves);
for (const [key, amount] of Object.entries(done.beforeRide.spice))
if (key !== 'hagga_basin:12') assert.equal(done.afterRide.spice[key], amount, 'A remote ride collects from its destination alone');
const invalidPile: Action = { ...done.ride.action, sandmasterPile: 'wind_pass:14' };
assert.throws(() => applyAction(done.beforeRide, 'fremen', invalidPile), /pile|spice|Sandmaster/i);
assert.throws(() => applyAction(done.beforeRide, 'fremen', { ...done.ride.action, sandmasterCollect: 'yes' } as unknown as Action), /Sandmaster|collect/i);
for (const collect of [false, undefined]) {
const plain = { ...done.ride.action };
delete plain.sandmasterCollect; delete plain.sandmasterPile;
const result = applyAction(done.beforeRide, 'fremen', { ...plain, ...(collect === undefined ? {} : { sandmasterCollect: collect }) });
assert.equal(player(result, 'fremen').forces['hagga_basin:12'], 3);
assert.equal(player(result, 'fremen').elites!.forces['hagga_basin:12'], 2);
assert.equal(player(result, 'fremen').spice, player(done.beforeRide, 'fremen').spice);
for (const [key, amount] of Object.entries(done.beforeRide.spice))
assert.equal(result.spice[key], amount, 'Declining collection leaves each existing pile unchanged');
assert.equal(result.spice['habbanya_erg:16'], 8,
'The original second Advanced spice pile continues after the first-pile remote ride');
}
for (const difficulty of DIFFICULTIES) {
const own = viewGame(done.beforeRide, 'fremen'); own.players.find(player => player.id === 'fremen')!.bot = difficulty;
const action = botActions(own)[0]; assert.ok(action);
const result = applyAction(done.beforeRide, 'fremen', action);
assert.equal(result.nexusFremenCunningRides!.find(ride => ride.occurrence.event === fixture.occurrence.event)!.stage, 'complete');
}
physicalNexus(done.game);
});
void test('Advanced actual Ixian first-Storm HMS placement at the native desert source admits the typed Cunning arrival into its interior', () => {
const fixture = createFremenCunningModulesFixture({ advanced: true, expansions: ['ix', 'choam', 'ecaz'],
factions: ['fremen', 'guild', 'emperor', 'ixians', 'choam', 'ecaz'],
skills: true, homeworlds: true, tech: true, strongholds: true, hmsAtSource: true });
const placement = fixture.hmsPlacement; assert.ok(placement);
assert.equal(placement.before.mobileStronghold!.location, null);
assert.equal(placement.after.mobileStronghold!.location, 'wind_pass:14');
assert.equal(placement.step.actor, 'ixians');
assert.equal(fixture.game.mobileStronghold!.location, 'wind_pass:14');
const done = completeFremenCunningModules(fixture, { destination: { territory: MOBILE_STRONGHOLD, sector: 0 } });
assert.equal(player(done.afterRide, 'fremen').forces[MOBILE_LOCATION], 3);
assert.equal(player(done.afterRide, 'fremen').elites!.forces[MOBILE_LOCATION], 2);
assert.equal(player(done.afterRide, 'fremen').forces[fixture.sourceKey] ?? 0, 0);
assert.equal(done.afterRide.mobileStronghold!.location, 'wind_pass:14');
assert.deepEqual(player(done.afterRide, 'ixians').forces, player(done.beforeRide, 'ixians').forces);
assert.deepEqual(player(done.afterRide, 'ixians').elites, player(done.beforeRide, 'ixians').elites);
assert.equal(player(done.afterRide, 'fremen').moved, player(done.beforeRide, 'fremen').moved);
assert.equal(player(done.afterRide, 'fremen').shipped, player(done.beforeRide, 'fremen').shipped);
assert.equal(player(done.afterRide, 'fremen').reserves, player(done.beforeRide, 'fremen').reserves);
assert.equal(player(done.afterRide, 'fremen').spice, player(done.beforeRide, 'fremen').spice);
physicalNexus(done.game);
});
@@ -0,0 +1,172 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { MOBILE_LOCATION, MOBILE_STRONGHOLD } from '../game/board';
import { clearNewlyRevealed, createDiscoveryState, placeDiscovery, revealDiscoveryToken } from '../game/discoveries';
import type { Game } from '../game/engine';
import { greatMakerSignature } from '../game/great-maker';
import { createHomeworldCustody } from '../game/homeworld-custody';
import { homeworldContext } from '../game/homeworld-game';
import {
captureFremenCunningOccurrence, fremenCunningDestinationBlock, fremenCunningPublicDestinationBlock,
quoteFremenCunning, quoteFremenCunningRide, validateFremenCunningSelection,
type FremenCunningOccurrence, type FremenCunningRideAuthorization, type FremenCunningSelection,
} from '../game/nexus-fremen-cunning';
import { nexusFixture } from './fixture-nexus-cards';
/** Pure quote position. The engine fixture lane owns genuine appearance,
* card acquisition and paid native arrivals; these tests do not certify setup. */
function position() {
const game = nexusFixture({ advanced: true });
game.turn = 2; game.phase = 1; game.storm = 0;
for (const player of game.players) {
player.forces = {};
if (player.elites) player.elites.forces = {};
}
const fremen = game.players.find(player => player.faction === 'fremen')!;
fremen.forces = { 'wind_pass:14': 3, 'wind_pass:15': 2, 'carthag:11': 1, 'polar_sink:0': 1 };
fremen.elites!.forces = { 'wind_pass:14': 1, 'wind_pass:15': 2 };
const cards = game.nexusCards!.cards!, index = cards.deck.indexOf('fremen');
assert.ok(index >= 0);
cards.hands[fremen.id] = cards.deck.splice(index, 1)[0];
return { game, fremen, other: game.players[1], third: game.players[2] };
}
function appearance(game: Game): FremenCunningOccurrence {
return captureFremenCunningOccurrence(game, {
event: 'accepted-empty-worm', territory: 'hagga_basin', origin: 'natural', pile: 0,
});
}
function authorize(game: Game, owner: string, occurrence: FremenCunningOccurrence): FremenCunningRideAuthorization {
const cards = game.nexusCards!.cards!;
assert.equal(cards.hands[owner], 'fremen');
cards.hands[owner] = null; cards.discard.push('fremen');
return { event: occurrence.event, owner, turn: occurrence.turn };
}
function revealLocation(game: Game) {
const supply = createDiscoveryState(() => 0.25);
const hiereg = supply.tokens.filter(token => token.type === 'hiereg');
const chosen = hiereg.findIndex(token => token.face === 'jacurutu-sietch');
assert.ok(chosen >= 0);
const hidden = placeDiscovery(supply, 'discovery-hagga-basin', () => (chosen + 0.25) / hiereg.length);
const token = hidden.tokens.find(token => token.face === 'jacurutu-sietch')!;
game.discoveryEnabled = true;
game.discoveries = clearNewlyRevealed(revealDiscoveryToken(hidden, token.id, game.turn - 1).state);
return { hidden, token: token.id };
}
const selected: Omit<FremenCunningSelection, 'destination'> = {
source: 'wind_pass', forces: {
'wind_pass:14': { normal: 1, elite: 1 }, 'wind_pass:15': { normal: 0, elite: 1 },
},
};
void test('revealed room-local Discovery accepts the original typed desert subset with World/all14/phase modules', () => {
const { game, fremen } = position();
const { hidden } = revealLocation(game);
game.expansions = ['ix', 'choam', 'ecaz']; game.ecazTreachery = true;
game.homeworlds = { custody: createHomeworldCustody(homeworldContext(game)) };
game.leaderSkills = {} as NonNullable<Game['leaderSkills']>;
game.techTokens = {} as NonNullable<Game['techTokens']>;
game.strongholdCards = {} as NonNullable<Game['strongholdCards']>;
fremen.forces['jacurutu-sietch:0'] = 1;
game.mobileStronghold = { location: 'wind_pass:14' };
fremen.forces[MOBILE_LOCATION] = 1;
const occurrence = appearance(game);
assert.deepEqual(quoteFremenCunning(game, fremen.id, occurrence)!.sources, [{
territory: 'wind_pass', sectors: {
'wind_pass:14': { normal: 2, elite: 1 }, 'wind_pass:15': { normal: 0, elite: 2 },
},
}], 'Reserves, World custody, rock, strongholds and nested territories are not desert sources.');
const authorization = authorize(game, fremen.id, occurrence);
const destination = { territory: 'jacurutu-sietch', sector: 0 };
assert.equal(fremenCunningPublicDestinationBlock(game, fremen.id, selected.source, destination), null);
assert.equal(fremenCunningDestinationBlock(game, fremen.id, occurrence, selected.source, destination), null);
const before = structuredClone(fremen);
assert.deepEqual(validateFremenCunningSelection(game, fremen.id, occurrence,
{ ...selected, destination }, authorization), { total: 3, elite: 2 });
assert.deepEqual(fremen, before);
assert.throws(() => validateFremenCunningSelection(game, fremen.id, occurrence,
{ ...selected, source: 'homeworld:fremen', destination }, authorization), /one occupied desert/);
assert.match(fremenCunningPublicDestinationBlock(game, fremen.id, selected.source,
{ territory: 'homeworld:atreides', sector: 0 })!, /Arrakis board sector/);
game.discoveries = hidden;
assert.match(fremenCunningPublicDestinationBlock(game, fremen.id, selected.source, destination)!, /Arrakis board sector/);
assert.throws(() => validateFremenCunningSelection(game, fremen.id, occurrence,
{ ...selected, destination }, authorization), /Arrakis board sector/);
});
void test('nested Discovery destinations retain public faction capacity, advisor stance and native Ecaz coalition rules', () => {
const { game, fremen, other, third } = position();
revealLocation(game);
const destination = { territory: 'jacurutu-sietch', sector: 0 };
other.forces['jacurutu-sietch:0'] = 1; third.forces['jacurutu-sietch:0'] = 1;
assert.match(fremenCunningPublicDestinationBlock(game, fremen.id, 'wind_pass', destination)!, /three occupying factions/);
other.faction = 'beneGesserit'; other.advisors = { 'jacurutu-sietch': {} };
assert.equal(fremenCunningPublicDestinationBlock(game, fremen.id, 'wind_pass', destination), null);
delete other.advisors;
assert.match(fremenCunningPublicDestinationBlock(game, fremen.id, 'wind_pass', destination)!, /three occupying factions/,
'The same BG counters fill a faction slot after changing to fighters.');
other.faction = 'ecaz'; other.ally = third.id; third.ally = other.id;
assert.equal(fremenCunningPublicDestinationBlock(game, fremen.id, 'wind_pass', destination), null,
'Ecaz and its real reciprocal ally share one destination faction.');
other.ally = null; third.ally = null;
fremen.ally = other.id; other.ally = fremen.id;
assert.equal(fremenCunningPublicDestinationBlock(game, fremen.id, 'wind_pass', destination), null,
'An actual Fremen/Ecaz alliance retains the native shared-occupation exception.');
other.faction = 'emperor';
assert.match(fremenCunningPublicDestinationBlock(game, fremen.id, 'wind_pass', destination)!, /occupied by your ally/);
});
void test('HMS entry uses the chosen actual desert source, not the initially empty worm territory', () => {
const { game, fremen } = position();
game.mobileStronghold = { location: 'wind_pass:14' };
const occurrence = appearance(game), authorization = authorize(game, fremen.id, occurrence);
const destination = { territory: MOBILE_STRONGHOLD, sector: 0 };
assert.notEqual(occurrence.territory, selected.source);
assert.equal(fremenCunningPublicDestinationBlock(game, fremen.id, selected.source, destination), null);
assert.deepEqual(validateFremenCunningSelection(game, fremen.id, occurrence,
{ ...selected, destination }, authorization), { total: 3, elite: 2 });
game.mobileStronghold.location = 'hagga_basin:12';
assert.match(fremenCunningPublicDestinationBlock(game, fremen.id, selected.source, destination)!, /territory it points at/);
assert.throws(() => validateFremenCunningSelection(game, fremen.id, occurrence,
{ ...selected, destination }, authorization), /territory it points at/);
game.mobileStronghold.location = 'wind_pass:99';
assert.match(fremenCunningPublicDestinationBlock(game, fremen.id, selected.source, destination)!, /territory it points at/);
game.mobileStronghold.location = null;
assert.match(fremenCunningPublicDestinationBlock(game, fremen.id, selected.source, destination)!, /Arrakis board sector/);
});
void test('storm sectors and multi-territory or non-desert source substitutions remain illegal with dynamic destinations', () => {
const { game, fremen } = position();
revealLocation(game);
const occurrence = appearance(game), authorization = authorize(game, fremen.id, occurrence);
const destination = { territory: 'jacurutu-sietch', sector: 0 };
game.storm = 14;
assert.deepEqual(quoteFremenCunningRide(game, fremen.id, occurrence, authorization)!.sources[0].sectors,
{ 'wind_pass:15': { normal: 0, elite: 2 } });
assert.throws(() => validateFremenCunningSelection(game, fremen.id, occurrence,
{ ...selected, destination }, authorization), /available typed physical forces/);
game.storm = 11;
assert.match(fremenCunningPublicDestinationBlock(game, fremen.id, selected.source,
{ territory: 'carthag', sector: 11 })!, /storm/);
assert.throws(() => validateFremenCunningSelection(game, fremen.id, occurrence,
{ ...selected, forces: { ...selected.forces, 'carthag:11': { normal: 1, elite: 0 } }, destination },
authorization), /available typed physical forces/);
assert.match(fremenCunningPublicDestinationBlock(game, fremen.id, selected.source,
{ territory: 'jacurutu-sietch', sector: 1 })!, /Arrakis board sector/);
});
void test('a completed earlier Great Maker does not manufacture or suppress a later ordinary empty occurrence', () => {
const { game, fremen, other } = position();
game.greatMaker = { event: 'earlier-maker', turn: game.turn, pile: 0, index: 0,
territory: 'red_chasm', stage: 'complete', order: [...game.order],
votes: game.order.map(player => ({ player, yes: false })), ridesBefore: [], arrival: null, signature: '' };
game.greatMaker.signature = greatMakerSignature(game.greatMaker);
const empty = appearance(game);
assert.ok(quoteFremenCunning(game, fremen.id, empty));
other.forces['hagga_basin:12'] = 1;
const occupied = captureFremenCunningOccurrence(game, {
event: 'later-occupied-worm', territory: 'hagga_basin', origin: 'additional', pile: 1,
});
delete other.forces['hagga_basin:12'];
assert.equal(quoteFremenCunning(game, fremen.id, occupied), null,
'Completed Maker custody does not turn a post-devour empty board into a qualifying occurrence.');
});