From 54c8030b9befb9a8b1882d56a7966a6ece0d6edf Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:27:14 +1000 Subject: [PATCH] Connect optional Sandmaster collection on native Fremen worm rides After legal placement, a living native Sandmaster may take one board spice from the single destination pile before faction arrival reactions. A ride has no intermediate ground route and never consumes normal movement. Multiple source sectors and typed elite forces earn one collection total. Each separate ride has its own opportunity. Decline and legacy actions retain ordinary riding; empty/ambiguous piles and unsupported module combinations suppress collection without preventing the ride. Share the public quote across server, destination checkbox and existing bot candidates. AI only gains minimal legal participation: no new destination policy or strength calibration. Keep base factions plus Leader Skills as the bounded scope; other expansion rosters/modules, Ecaz treachery and multiple positive piles remain guarded. Record the composition of printed worm-movement wording with the existing Sandmaster contract, including the existing per-distinct-movement inference. No combined-effect FAQ or complete module compliance is claimed. The room's atomic versioned write settles the physical transfer once before a pending Bene Gesserit reaction. Saved continuation resumes only the arrival and remaining ride queue. Ten new engine, control and SQLite cases cover Basic/Advanced custody, opt-out, legacy fields, rejected-action immutability, trainer visibility/control, public mode guards, private views, simultaneous duplicates, restarted arrivals, distinct queued rides and a genuine special-Karama worm through Nexus. Correct the shared shipment fixture to retain each faction's actual elite total and support Bene Gesserit as the opponent. Independent source and implementation review found no material defects. Focused work found and fixed the generic decision handler clearing the decision before the new quote, plus test-only fixture/type issues. Types, lint, 4894 offline tests, production build and 43 HTTP tests passed on unchanged source. Two genuine four-profile Leader Skills samples finished in 265 Basic and 211 Advanced actions, no rejected candidates, and 14 JSON restorations. Neither naturally encountered the new effect; targeted checks establish it. This is playability evidence, not AI calibration. Isolated browser room RMMA6LEF used a normally created Fremen seat and a conserved staged Advanced Leader Skills position. Selected two forces from each of two sectors, including one elite each, toggled collection off and on, then rode to Red Chasm. The UI showed four arrived forces and balance six before the opponent reaction. Refresh preserved that pending decision, balance and explanatory once-only collection history. The opponent's production-engine arrival response resumed to CHOAM charity; another refresh retained six spice and the private Chani traitor. Seat rows were unchanged. This is a targeted staged browser journey; screenshot and complete human-game acceptance remain open. All 1140 original rooms, 2385 seats and every original recovery, entry and handover row remain unchanged. HTTP/browser checks added 34 rooms and 71 seats. SQLite integrity and foreign keys pass. No restart, reset, migration, maintenance automation or usage check was needed. No mode or publication gate is opened. Verified source tree: 7996a37c13c71992d9bb6baf7016558615b98db064eda300ce19604b429b0d73 --- components/game-table.tsx | 7 ++ components/sandmaster-worm.tsx | 20 ++++ docs/CURRENT_STATUS.md | 25 ++-- docs/IMPLEMENTATION_STATUS.md | 12 ++ docs/LEADER_SKILLS_RUNTIME.md | 6 +- docs/RULE_DECISIONS.md | 2 +- docs/SANDMASTER_MOVEMENT.md | 5 +- docs/SANDMASTER_WORM.md | 58 +++++++++ game/bots.ts | 3 + game/engine.ts | 14 +++ game/reference.ts | 16 +-- game/sandmaster-worm.ts | 49 ++++++++ tests/fixture-sandmaster-worm.ts | 27 +++++ tests/sandmaster-worm-controls.test.tsx | 27 +++++ tests/sandmaster-worm-recovery.test.ts | 66 +++++++++++ tests/sandmaster-worm.test.ts | 149 ++++++++++++++++++++++++ tests/smuggler-shipment-fixture.ts | 8 +- 17 files changed, 466 insertions(+), 28 deletions(-) create mode 100644 components/sandmaster-worm.tsx create mode 100644 docs/SANDMASTER_WORM.md create mode 100644 game/sandmaster-worm.ts create mode 100644 tests/fixture-sandmaster-worm.ts create mode 100644 tests/sandmaster-worm-controls.test.tsx create mode 100644 tests/sandmaster-worm-recovery.test.ts create mode 100644 tests/sandmaster-worm.test.ts diff --git a/components/game-table.tsx b/components/game-table.tsx index 560ef210..cbb2d21c 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -61,6 +61,8 @@ import { OrnithopterMovement } from './ornithopter-movement'; import { DiscoveryOrnithopterMovement } from './discovery-flight-movement'; import { PlanetologistMovement } from './planetologist-movement'; import { SandmasterMovement } from './sandmaster-movement'; +import { SandmasterWormChoice } from './sandmaster-worm'; +import { sandmasterWormCollection } from '@/game/sandmaster-worm'; import { ResidualPoison, BattleLeaderOpportunity } from './residual-poison'; import { PortableSnooper } from './portable-snooper'; import { @@ -427,6 +429,8 @@ export function GameTable({ const [moveNoField, setMoveNoField] = useState(false); const [moveForces, setMoveForces] = useState>({}); const [rideForces, setRideForces] = useState>({}); + const [collectSandmasterRide, setCollectSandmasterRide] = useState(true); + const sandmasterRide = sandmasterWormCollection(g, me.id, selected, sector); const [exchangeCards, setExchangeCards] = useState>( {}, ); @@ -3193,8 +3197,11 @@ export function GameTable({ } /> ))} + {actionButton('Ride Shai-Hulud', { type: 'decision', + ...(sandmasterRide?.blocked === null ? { sandmasterCollect: collectSandmasterRide } : {}), eliteForces, accept: true, territory: selected, diff --git a/components/sandmaster-worm.tsx b/components/sandmaster-worm.tsx new file mode 100644 index 00000000..ef21f9c0 --- /dev/null +++ b/components/sandmaster-worm.tsx @@ -0,0 +1,20 @@ +import type { SandmasterWormCollection } from '@/game/sandmaster-worm'; + +export function SandmasterWormChoice({ quote, collect, onChange }: { + quote: SandmasterWormCollection | null; + collect: boolean; + onChange: (collect: boolean) => void; +}) { + if (!quote) return null; + return
+ {quote.blocked ?

{quote.blocked} You may still ride without collecting.

: <> + +

Collect once for this ride, even when several forces or source + sectors participate. The source and intervening territories earn nothing. + This does not use your normal movement.

+ } +
; +} diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 07d5bb62..79ae464d 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -6,19 +6,20 @@ gated.** Prototypes do not open completion or publication gates. ## Current checkpoint and work -Latest delivered checkpoint: `9be7dfb`, the [interactive introduction](INTERACTIVE_INTRODUCTION.md). -Types, lint, **4,872 offline tests** and production build passed. Five lessons -connect shared-rule shipping, sealed battle and collection exercises, card -inspection and browser-local continuation. Broader teaching remains unfinished. -After the reported outage all 1,106 games matched the prior backup and a private -QA seat restored after refresh. The healthy server was reused without a reset. +Latest delivered gameplay checkpoint: `20cc605`, [Smuggler battle collection](SMUGGLER_BATTLE.md). +Types, lint, **4,884 offline tests**, production build and **43 HTTP tests** passed. +The surviving unmodified leader collects even after losing or capture, with saved +settlement before Sandmaster. All 1,106 baseline rooms and 2,314 seats stayed +unchanged; isolated checks added 34 rooms and 71 seats. No restart was needed. +The subsequent `8f7a62c` policy checkpoint removed the usage cutoff and deferred +AI refinement until feature completion, with three adjacent 75% win-rate targets. Earlier evidence remains in the [implementation history](IMPLEMENTATION_STATUS.md). -Current work: [Smuggler battle collection](SMUGGLER_BATTLE.md) connects the -unmodified lower band, automatic survival settlement, losing/captured leaders, -Sandmaster order, shared controls and bots, and saved continuation. Modified -strength and combined modules remain guarded. Final verification and delivery -belong to the source-bound private report and Git message. +Current work: [Sandmaster worm rides](SANDMASTER_WORM.md) connect optional native +Fremen destination collection, multi-sector forces, controls, minimal legal AI +participation and saved Bene Gesserit arrival continuation. Multiple piles and +combined modules remain guarded. Final verification and delivery belong to the +source-bound private report and Git message. The connected [Harass & Withdraw prototype](HARASS_WITHDRAW_RUNTIME.md) supports either battle-card slot, private category inspections, exact undialed normal/elite @@ -75,7 +76,7 @@ These links define bounded working behavior, not complete module certification. | --- | --- | | Base/Advanced setup | [Genuine setup](ADVANCED_SETUP_TEST_SEAM.md), [Advanced study](AI_ADVANCED_SETUP_CALIBRATION_20260907.md), [Fremen cancellation](FREMEN_MOVEMENT_KARAMA_RULES.md), [Atreides full-plan audit](ATREIDES_FULL_PLAN_TIMING.md). Earlier Basic-then-flip fixtures do not establish current setup coverage. | | Expansion factions | [Ixians/Tleilaxu](IX_PROTOTYPE.md) and [all seven faction selections](EXPANSION_FACTIONS_PROTOTYPE.md): genuine setup, Ecaz six-force placement, separate optional card variants, private controls and saved per-lot Ixian/Richese decline. Actual special-lot exchange remains pending. | -| Leader Skills | [Common lifecycle](LEADER_SKILLS_RUNTIME.md), [known skilled capture](LEADER_SKILLS_CAPTURE.md), [Mentat question preview](MENTAT_QUESTION.md), [Bureaucrat payments](BUREAUCRAT_PAYMENTS.md), five battle disciplines, [Planetologist](PLANETOLOGIST_RULES.md), [Suk Graduate](SUK_GRADUATE_RULES.md), [Rihani and other battle effects](LEADER_BATTLE_EFFECTS.md), [Smuggler shipment](SMUGGLER_SHIPMENT.md), [No-Field](SMUGGLER_NO_FIELD.md) and [battle collection](SMUGGLER_BATTLE.md), [Sandmaster routes](SANDMASTER_MOVEMENT.md), [Banker spending](SPICE_BANKER_RUNTIME.md), [Diplomat defense](DIPLOMAT_DEFENSE.md). Remaining bands and combinations are explicit in those contracts. | +| Leader Skills | [Common lifecycle](LEADER_SKILLS_RUNTIME.md), [known skilled capture](LEADER_SKILLS_CAPTURE.md), [Mentat question preview](MENTAT_QUESTION.md), [Bureaucrat payments](BUREAUCRAT_PAYMENTS.md), five battle disciplines, [Planetologist](PLANETOLOGIST_RULES.md), [Suk Graduate](SUK_GRADUATE_RULES.md), [Rihani and other battle effects](LEADER_BATTLE_EFFECTS.md), [Smuggler shipment](SMUGGLER_SHIPMENT.md), [No-Field](SMUGGLER_NO_FIELD.md) and [battle collection](SMUGGLER_BATTLE.md), [Sandmaster routes](SANDMASTER_MOVEMENT.md) and [worm rides](SANDMASTER_WORM.md), [Banker spending](SPICE_BANKER_RUNTIME.md), [Diplomat defense](DIPLOMAT_DEFENSE.md). Remaining bands and combinations are explicit in those contracts. | | Discoveries | [Prototype](DISCOVERY_PROTOTYPE.md): genuine setup, Great Maker, seven cards/eight tokens, inspection and stash rewards, nested sites, signed later free entry, carried Ornithopter, sole Cistern, bounded Jacurutu income, Testing Station and Shrine. Orgiz and contested/mixed cases remain pending. | | Nexus/Homeworlds | [Decision index](RULE_DECISIONS.md) links each integrated faction family, native/borrowed effects, physical custody, private choices, payments and transport. [CHOAM Collection trade](NEXUS_CHOAM_SECRET_ALLY.md) and [Emperor extra revival](NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md) are connected; CHOAM inspection and Emperor purchase remain pending. A source audit or helper alone is not a completed effect. | | Truthtrance | [Spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card counts](TRUTHTRANCE_CARD_COUNT.md), [hand inventory](TRUTHTRANCE_HAND_INVENTORY.md), [recorded knowledge](TRUTHTRANCE_KNOWLEDGE.md), and [Basic/no-Guild Advanced reserve-shipment promises](TRUTHTRANCE_SHIPMENT_PROMISES.md) use authoritative private state. Earlier readiness audits do not supersede these follow-ups. | diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index d8f6fcd8..db10b6b5 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,17 @@ # Dune implementation status +## 20 September 2026 — Sandmaster worm-ride prototype + +[Native Fremen worm rides](SANDMASTER_WORM.md) collect one optional destination +spice after legal placement and before faction arrival reactions. Shared server, +control and minimal AI paths preserve source exclusion, typed force custody and +normal movement. Existing atomic room writes and saved arrival decisions prevent +repeated payment after concurrent requests or restoration. Empty/ambiguous piles, +unsupported combinations and legacy actions preserve ordinary riding. Ten focused +engine/control/SQLite checks and independent review cover the bounded contract; +final check/build/HTTP, browser/sample and preservation results belong to the +source-bound private checkpoint and Git message. No release gate is opened. + ## 20 September 2026 — Smuggler battle collection prototype [Unmodified Smuggler collection](SMUGGLER_BATTLE.md) binds the reveal-time pile diff --git a/docs/LEADER_SKILLS_RUNTIME.md b/docs/LEADER_SKILLS_RUNTIME.md index cbfc8404..b7ce31ae 100644 --- a/docs/LEADER_SKILLS_RUNTIME.md +++ b/docs/LEADER_SKILLS_RUNTIME.md @@ -165,5 +165,7 @@ recorded in the commit and private verification report. Full module and combined game acceptance remain unfinished. The [Sandmaster ground-movement follow-up](SANDMASTER_MOVEMENT.md) adds explicit -routes, optional collection, human/AI controls and saved cancellation. Special -relocation and combined modules remain unfinished. +routes, optional collection, human/AI controls and saved cancellation. The +[worm-ride follow-up](SANDMASTER_WORM.md) adds native Fremen destination collection +and saved arrival reactions. Other special relocation and combined modules +remain unfinished. diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 0ca7ad4d..25b52027 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -12,7 +12,7 @@ Updated 14 September 2026. This is the authoritative navigation index for existi | Ixian Technology on Richese lots | Replacement-card ownership, income and unsold disposition remain unresolved. A saved explicit per-lot decline permits an unchanged offer to proceed and leaves Technology available later. This development continuation does not implement the exchange or settle its custody. | [Auction source boundary](RICHESE_AUCTION_RULES.md#13-september-saved-per-lot-technology-decline) | | Spice Banker battle spending | Secret own-spice commitment is separate from support; only the surviving selected disc gains strength. Core Battle Plan payment rules spend it on death/loss and waive it for a sole traitor-call winner. This payment composition is explicit; upper deferred-income custody remains pending. | [Source contract and prototype](SPICE_BANKER_RUNTIME.md) | | Smuggler shipping | Optional free companion is included in the actual total; apply Guild rounding to the reduced priced count. Ordinary Fremen reserves are on planet. The officially permitted owned No-Field plus a separate free force is now prototyped at the unchanged one-marker price. Legacy unstamped ordinary declarations keep their quoted full price. | [Ordinary contract](SMUGGLER_SHIPMENT.md), [No-Field combination](SMUGGLER_NO_FIELD.md) | -| Sandmaster movement | One optional board spice per distinct entered territory on an explicit legal route; source-only repositioning earns none. Per-movement Hajr reset is a documented grammatical inference. Canceled moves earn nothing; multiple-pile collection remains guarded. | [Source and connected prototype](SANDMASTER_MOVEMENT.md) | +| Sandmaster movement | One optional board spice per distinct entered territory on an explicit legal route; source-only repositioning earns none. Per-movement Hajr reset is a documented grammatical inference. Canceled moves earn nothing. Native Fremen worm rides collect optionally at the destination before faction arrival reactions, with no intermediate route. Multiple-pile collection remains guarded. | [Ground routes](SANDMASTER_MOVEMENT.md), [worm rides](SANDMASTER_WORM.md) | | Diplomat defense | With no actual defense, one physical Worthless card in either Battle Plan slot may copy the opponent's canonical base Shield or Snooper. The same native trained disc may protect itself. The public after-reveal/before-traitor choice and mandatory discard after traitor preemption are documented implementation inferences. Lower retreat and modified or multirole defenses remain guarded. | [Source and connected prototype](DIPLOMAT_DEFENSE.md) | | Bureaucrat payments | Once per phase, optionally divert two from an actual third-party payment of at least five; payer cost is unchanged. Bribes trigger at transfer to the recipient's shield, not later collection. Native income is offered after Karama allowance as an explicit outcome-based inference; split contributions, split recipients and remaining payment families need further integration. | [Source and connected boundary](BUREAUCRAT_PAYMENTS.md) | | Mentat question preview | The physical card says to show the card to the questioner, privately; it stays in hand and does not bind a plan. Ask before posture/faction powers. A uniform target response protects held/absent privacy but awaits approval as an exception to skipping forced confirmations; standard activation stays disabled. Empty hands skip with no fabricated answer. | [Source and preview](MENTAT_QUESTION.md) | diff --git a/docs/SANDMASTER_MOVEMENT.md b/docs/SANDMASTER_MOVEMENT.md index 3689c0d7..adfcf0fc 100644 --- a/docs/SANDMASTER_MOVEMENT.md +++ b/docs/SANDMASTER_MOVEMENT.md @@ -69,7 +69,8 @@ reserves, no Tanks and the same private Crysknife and Caid. Movement completion awaited the human. This is targeted browser evidence, not a complete human game. Broad checkpoint results are recorded with the checkpoint. These checks do not certify every skill or combined module. -Remaining work includes worm riding and other nonordinary relocation, special -movement cards, expansion/module integration, multiple-pile adjudication and +The [worm-ride follow-up](SANDMASTER_WORM.md) connects native Fremen destination +collection, including saved arrival reactions. Remaining work includes other +nonordinary relocation, special movement cards, expansion/module integration, multiple-pile adjudication and wider interaction and strategy acceptance. The lower battle band remains in [the battle-effects contract](LEADER_BATTLE_EFFECTS.md). diff --git a/docs/SANDMASTER_WORM.md b/docs/SANDMASTER_WORM.md new file mode 100644 index 00000000..d410b13d --- /dev/null +++ b/docs/SANDMASTER_WORM.md @@ -0,0 +1,58 @@ +# Sandmaster worm-ride collection + +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. + +## Source contract + +The [existing Sandmaster contract](SANDMASTER_MOVEMENT.md#source-contract), +checked physical card and designer walkthrough allow one spice when forces move +into or through a territory containing spice. The [GF9 core rulebook, Shai-Hulud +and Fremen sections](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf) +calls riding a move of some or all forces to a destination after Nexus, subject +to storm and occupancy. The archived core text is identified in the +[source archive](LEADER_SKILLS_RULES.md#sources-and-local-evidence). + +Applying that movement wording gives one optional spice at the destination; +a worm ride specifies no traversed route. The source earns nothing. Separate +rides are distinct movements under the existing explicit per-movement inference. +This composes the printed rules; it is not a newly located combined-effect FAQ. +The native trainer must be alive, uncaptured and available for its normal band. +Collection follows legal placement and precedes faction arrival reactions under +the common skill-before-faction order. Subsequent arrival effects do not undo it. + +## Connected behavior + +A shared public quote validates the player, destination, skill and single positive +pile. The checkbox defaults to collection but permits declining it. An empty or +ambiguous pile suppresses collection without blocking ordinary riding. Legacy +saved actions without the new field retain their original behavior. No invented +intermediate route or extra normal movement is consumed. Multi-sector and typed +elite selections transfer one spice total, not one per force or source sector. + +The engine validates every force before moving spice from the board to the +player, logs the automatic effect and then opens any Bene Gesserit arrival choice. +Existing versioned room writes commit that transfer once. A saved pending arrival +resumes only the remaining reaction and ride queue; no new receipt is needed. +All four AI profiles attach the optional collection flag to the existing legal +ride candidates. Destination policy and strength tuning are unchanged. + +## Verification and remaining work + +Focused engine, controls and SQLite checks cover Basic/Advanced force custody, +source exclusion, decline, omitted legacy fields, empty and ambiguous piles, +illegal requests without mutation, native trainer ownership/visibility, public +mode guards, private views, duplicate concurrent requests and restart during a +Bene Gesserit reaction. Separate queued rides and a real special-Karama worm +through Nexus exercise the production continuation. The fixture preserves the +Fremen three-elite total rather than assuming the Emperor five-elite total. +Independent source and implementation review found no material defect in this +bounded contract. Final check/build/HTTP results, targeted browser continuation, +sample games, saved-game preservation and Git delivery belong to the private +source-bound checkpoint and commit message. + +Other expansion factions/modules, the Ecaz card variant and multiple positive +piles remain guarded. Other special relocation, full Leader Skills integration, +complete human games and final presentation remain unfinished. Strategy refinement +waits for the [AI feature-completion gate](AI_DEVELOPMENT_PLAN.md). diff --git a/game/bots.ts b/game/bots.ts index bd1812e8..8a674d3f 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -85,6 +85,7 @@ import { } from './bot-mobility'; import { planetologistLeader } from './planetologist-movement'; import { sandmasterDefaultChoice } from './sandmaster-movement'; +import { sandmasterWormCollection } from './sandmaster-worm'; import { faction } from './catalog'; import { richeseCardDefinition } from './richese-cards'; import { presenceAt } from './force-presence'; @@ -2591,6 +2592,8 @@ function policyActions(g: GameView): Action[] { accept: true, territory: to.t, sector: to.s, + ...(sandmasterWormCollection(g, me.id, to.t, to.s)?.blocked === null + ? { sandmasterCollect: true } : {}), forces: Object.fromEntries( Object.entries(me.forces).filter( ([key]) => diff --git a/game/engine.ts b/game/engine.ts index 98d220e3..fa66fe07 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -14,6 +14,7 @@ import { quoteSmugglerNoField, smugglerNoFieldModeSupported, type SmugglerNoFiel import { quoteSmugglerShipment, type SmugglerShipment } from './smuggler-shipment'; import { createSmugglerBattle, settleSmugglerBattle, smugglerBattleModeSupported, smugglerBattlePlanBlock, smugglerBattlePile, smugglerBattleSignature, type SmugglerBattleReceipt } from './smuggler-battle'; import { quoteSandmasterMovement, validateSandmasterMovement, type SandmasterMovement, type SandmasterOrder } from './sandmaster-movement'; +import { sandmasterWormCollection } from './sandmaster-worm'; import { spiceBankerModeSupported, validateSpiceBankerSpend } from './spice-banker'; import { quoteDiplomatDefense, diplomatDefenseModeSupported, type DiplomatDefenseQuote } from './diplomat-defense'; import { ECAZ_START_FORCES, quoteEcazStartingForces } from './ecaz-setup'; @@ -22257,6 +22258,10 @@ function applyActionInner( typeof action.accept === 'boolean', 'Choose whether to ride Shai-Hulud.', ); + requireRule(action.sandmasterCollect === undefined || typeof action.sandmasterCollect === 'boolean', + 'Choose whether to collect spice with Sandmaster.'); + requireRule(action.accept || !action.sandmasterCollect, + 'Sandmaster collection requires an accepted worm ride.'); if (action.accept) { const to = stringField(action.territory); const sector = integer(action.sector, 0, 18, 'Sector'); @@ -22265,6 +22270,9 @@ function applyActionInner( 'Choose another destination territory.', ); allowedEntry(g, p, to, sector); + const sandmaster = action.sandmasterCollect ? sandmasterWormCollection(g, p.id, to, sector, decision) : null; + if (action.sandmasterCollect) requireRule(sandmaster && !sandmaster.blocked, + sandmaster?.blocked ?? 'Sandmaster collection needs the living native trainer.'); requireRule( action.forces && typeof action.forces === 'object' && @@ -22302,6 +22310,12 @@ function applyActionInner( g, `${p.name} rode Shai-Hulud with ${total} forces to ${territory(to).name}.`, ); + if (sandmaster) { + g.spice[sandmaster.key!] = sandmaster.before - 1; + p.spice++; + log(g, `${p.name}'s Sandmaster collected 1 spice on arriving in ${territory(to).name} by worm. ${sandmaster.before - 1} spice remains in that pile. This ride does not consume normal movement.`, + { faction: p.faction, name: 'Sandmaster collection' }); + } const intruded = intrusion(g, p, to, { wormRide: true }); if ( openTerritoryEntry( diff --git a/game/reference.ts b/game/reference.ts index 475016ed..ed8a95a7 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3699,7 +3699,7 @@ export const RULE_TOPICS: RuleTopic[] = [ category: 'Advanced & expansions', coverage: 'Partial', developmentStage: 'Prototyped', - summary: 'Fourteen physical skills, private assignment choices, five battle disciplines, Planetologist movement/battle effects and Suk Graduate rescues, Mentat/Bureaucrat scoring and Bureaucrat payment redirection, Sandmaster victory spice, Rihani exchanges, optional Smuggler shipping and unmodified battle collection, Sandmaster route collection, sealed Spice Banker battle spending and optional Diplomat base-defense copying in development tables. Other effects and combined-module acceptance remain unfinished.', + summary: 'Fourteen physical skills, private assignment choices, five battle disciplines, Planetologist movement/battle effects and Suk Graduate rescues, Mentat/Bureaucrat scoring and Bureaucrat payment redirection, Sandmaster victory spice, Rihani exchanges, optional Smuggler shipping and unmodified battle collection, Sandmaster route and worm-ride collection, sealed Spice Banker battle spending and optional Diplomat base-defense copying in development tables. Other effects and combined-module acceptance remain unfinished.', steps: [ 'With this optional module, starting Treachery Cards are dealt after prediction and before skill assignment. Every faction privately receives two skills, keeps one, returns the other to the shuffled deck, and assigns its chosen card to an eligible leader. Skill and leader become public before traitor selection.', 'Before Battle Plans, leave the skilled leader face up to keep its normal effect, or move it behind the shield for possible use. A face-up leader cannot be selected for the plan. Concealing it while choosing another leader gives neither effect. A leader that must be used because no alternative remains moves behind the shield automatically.', @@ -3709,7 +3709,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Planetologist chooses either one extra movement range (maximum three) or forces from two territories to one destination at normal range. These alternatives do not stack. The actual skilled leader can use an eligible green Special in the weapon slot for +2 if it survives; discard that Special after battle without activating its ordinary effect. Mixed advisor/fighter entry into an enemy-only territory and combined expansion modules remain unfinished.', 'Suk Graduate returns one selected casualty to reserves while its leader stays face up, or lets its surviving battle leader save up to three: one stays in its original sector and the rest return to reserves. The effects do not stack. Advanced Atreides awaits the Kwisatz Haderach loss-count ruling; combined modules remain unfinished.', 'A surviving selected Mentat adds two battle strength. A surviving selected Bureaucrat subtracts one from the opponent’s total per distinct stronghold occupied by that opponent; advisors do not count. Printed disc values and bounties stay unchanged.', - 'A living native Sandmaster may collect one spice per entered territory along a declared legal ground route. Choose each collection separately; canceled moves earn nothing. The initial source and a sector-only reposition earn nothing. Separate movements have separate collection opportunities. Multiple piles and special relocation remain unfinished.', + 'A living native Sandmaster may collect one spice per entered territory along a declared legal ground route. Choose each collection separately; canceled moves earn nothing. The initial source and a sector-only reposition earn nothing. Separate movements have separate collection opportunities. Native Fremen worm rides may collect once at the destination before faction arrival reactions, without a ground route or normal movement use. Multiple piles and other special relocation remain unfinished.', 'A surviving skilled Sandmaster victory automatically adds three board spice to an existing pile in the battle territory, even with no surviving forces. Multiple piles await a placement ruling.', 'Rihani normally inspects two random Traitor Deck cards privately, then reshuffles them. A surviving selected native Rihani also offers a separate optional draw of two. Decide before seeing them: once drawn, keep one new card and publicly reveal and return one unused old card. The other new card returns secretly. A captured Rihani grants only the optional exchange. Your private history records both samples; a card just called to win is already used.', 'Smuggler can include one free accompanying force in an off-planet shipment into an empty territory. Select the entire physical total; three forces cost the ordinary price of two. The checkbox can decline this optional benefit. Guild rounding follows the free force. Richese may instead ship one separate free reserve force beside its owned No-Field into an empty territory; the marker retains its ordinary price and hidden value. Existing forces, including advisors, prevent the bonus; ordinary Fremen on-planet reinforcement has no bonus.', @@ -3718,16 +3718,16 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Mentat has a private developer preview for naming a specific weapon before posture and faction powers. The target privately shows the named held card or chooses another held card. It stays in hand and need not enter a Battle Plan. Every answer uses the same private response step; ordinary activation awaits the user’s timing-design decision. Empty hands skip without an answer. Private history records an observation, not a later hand guarantee.', 'Bureaucrat may redirect two spice from a payment of at least five between two other players, once per phase. The payer still pays in full; the recipient receives two less. Declining keeps the use available for a later payment. Paid bribes qualify when transferred, with the remaining share still unavailable until Mentat Pause. Ordinary auctions, Guild income and Richese sales have connected payment paths; split funding, other payment families and combined modules remain unfinished.', 'Smuggler records the existing battle-territory spice when plans reveal, capped by the selected leader’s unmodified strength. If that leader survives, it collects automatically even when losing or no forces remain. A captive pays its controller. Pending spice cannot fund the plan. Collection precedes Sandmaster’s addition; a drained pile receives none. Modified strength, multiple positive piles and combined modules remain guarded. Older saved battles keep their original behavior.', - 'Special Sandmaster relocation, Spice Banker normal income and Diplomat retreat remain missing. Foreign gholas, Face Dancers and combined modules remain unfinished. This development prototype does not enable public module starts.', + 'Other special Sandmaster relocation, Spice Banker normal income and Diplomat retreat remain missing. Foreign gholas, Face Dancers and combined modules remain unfinished. This development prototype does not enable public module starts.', ...LEADER_SKILL_CARDS.map(card => `${card.name}. Normal: ${card.normal.join(' ')} Skilled battle: ${card.battle.join(' ')}`), ], related: ['setup','choam-modules','implementation-checklist'], checklist: [ - { area:'Implementation',status:'Partial',detail:'Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. Rihani inspection/exchange and lower Mentat/Bureaucrat/Sandmaster effects are connected. Smuggler normal reserve shipping, the owned Richese No-Field companion, unmodified battle collection and optional Sandmaster ground-route collection are connected. A separately opted-in Mentat preview connects private pre-plan questions and historical observations while ordinary activation awaits the uniform-response decision. Spice Banker lower spending and native Diplomat base Shield/Snooper copying are connected; Spice Banker normal income, other remaining bands, Diplomat retreat and combined modules remain missing. Bureaucrat redirects supported third-party auction, shipment and bribe payments with exact saved once-per-phase use.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/mentat-question.ts','game/bureaucrat-payment.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/smuggler-no-field.ts','game/smuggler-battle.ts','game/spice-banker.ts','game/sandmaster-movement.ts','game/diplomat-defense.ts','game/engine.ts'] }, - { area:'Player controls',status:'Partial',detail:'Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Smuggler has explicit ordinary shipping opt-out and No-Field companion opt-in with physical-versus-priced force counts, plus automatic surviving-leader battle collection with reveal-time guidance. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes and per-territory collection choices. Diplomat offers a named committed Worthless choice or decline after public reveal. Bureaucrat offers redirect or full payment with explicit recipient amounts and deferred bribe custody. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/mentat-question.tsx','components/bureaucrat-payment.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/diplomat-defense.tsx','components/game-table.tsx'] }, - { area:'AI',status:'Partial',detail:'All four profiles use private offers, legal visibility/revival actions and Planetologist movement/battle candidates and legal Suk Graduate rescues, Rihani exchanges, shared battle scoring, Smuggler reserve/owned No-Field shipments and unmodified collection plans, Sandmaster route collection funded Banker commitments and optional Diplomat copying from public revealed defenses. Bureaucrat uses the public payee alliance to redirect enemy income or preserve allied income. These are legal prototype paths; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] }, - { area:'Documentation',status:'Partial',detail:'All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody; replacement entitlement and other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md','docs/PLANETOLOGIST_RULES.md','docs/SUK_GRADUATE_RULES.md','docs/LEADER_BATTLE_EFFECTS.md','docs/MENTAT_QUESTION.md','docs/BUREAUCRAT_PAYMENTS.md','docs/SMUGGLER_SHIPMENT.md','docs/SPICE_BANKER_RUNTIME.md','docs/DIPLOMAT_DEFENSE.md'] }, - { area:'Verification',status:'Partial',detail:'Focused inventory, scoring, Voice, capture, saved private setup/revival Planetologist movement/cancellation and Suk Graduate casualty/SQLite checks, plus Rihani privacy/concurrent recovery battle-effect regressions and Smuggler shipment and battle collection, losing/captured survival, Sandmaster order, explosion, legacy and concurrent recovery, and Banker funding, death, traitor, privacy and concurrent settlement checks cover this boundary. Diplomat checks cover self-protection, mandatory discard, public-choice privacy, concurrent saved decisions and legacy continuation. Bureaucrat checks cover once-per-phase use, private source receipts and resumed payment custody. Sample games and browser continuation are recorded separately; full module acceptance remains open.',evidence:['tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/leader-skills-recovery.test.ts','tests/leader-skills-controls.test.ts','tests/suk-graduate.test.ts','tests/suk-graduate-recovery.test.ts','tests/leader-battle-effects.test.ts','tests/leader-battle-effects-recovery.test.ts','tests/leader-battle-effects-controls.test.tsx','tests/mentat-question.test.ts','tests/mentat-question-bots.test.ts','tests/mentat-question-controls.test.tsx','tests/mentat-question-recovery.test.ts','tests/bureaucrat-payment.test.ts','tests/bureaucrat-payment-controls.test.tsx','tests/bureaucrat-payment-bots.test.ts','tests/bureaucrat-payment-recovery.test.ts','tests/smuggler-battle.test.ts','tests/smuggler-battle-recovery.test.ts','tests/smuggler-shipment.test.ts','tests/smuggler-shipment-controls.test.tsx','tests/smuggler-shipment-recovery.test.ts','tests/smuggler-no-field.test.ts','tests/smuggler-no-field-controls.test.tsx','tests/smuggler-no-field-recovery.test.ts','tests/spice-banker.test.ts','tests/spice-banker-recovery.test.ts','tests/spice-banker-controls.test.tsx','tests/sandmaster-movement.test.ts','tests/sandmaster-movement-recovery.test.ts','tests/sandmaster-movement-controls.test.tsx','tests/diplomat-defense.test.ts','tests/diplomat-defense-controls.test.tsx','tests/diplomat-defense-recovery.test.ts'] }, + { area:'Implementation',status:'Partial',detail:'Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. Rihani inspection/exchange and lower Mentat/Bureaucrat/Sandmaster effects are connected. Smuggler normal reserve shipping, the owned Richese No-Field companion, unmodified battle collection and optional Sandmaster ground-route and native Fremen worm-ride collection are connected. A separately opted-in Mentat preview connects private pre-plan questions and historical observations while ordinary activation awaits the uniform-response decision. Spice Banker lower spending and native Diplomat base Shield/Snooper copying are connected; Spice Banker normal income, other remaining bands, Diplomat retreat and combined modules remain missing. Bureaucrat redirects supported third-party auction, shipment and bribe payments with exact saved once-per-phase use.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/mentat-question.ts','game/bureaucrat-payment.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/smuggler-no-field.ts','game/smuggler-battle.ts','game/spice-banker.ts','game/sandmaster-movement.ts','game/sandmaster-worm.ts','game/diplomat-defense.ts','game/engine.ts'] }, + { area:'Player controls',status:'Partial',detail:'Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Smuggler has explicit ordinary shipping opt-out and No-Field companion opt-in with physical-versus-priced force counts, plus automatic surviving-leader battle collection with reveal-time guidance. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes, per-territory collection choices and an optional worm-destination checkbox. Diplomat offers a named committed Worthless choice or decline after public reveal. Bureaucrat offers redirect or full payment with explicit recipient amounts and deferred bribe custody. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/mentat-question.tsx','components/bureaucrat-payment.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/sandmaster-worm.tsx','components/diplomat-defense.tsx','components/game-table.tsx'] }, + { area:'AI',status:'Partial',detail:'All four profiles use private offers, legal visibility/revival actions and Planetologist movement/battle candidates and legal Suk Graduate rescues, Rihani exchanges, shared battle scoring, Smuggler reserve/owned No-Field shipments and unmodified collection plans, Sandmaster route and worm collection, funded Banker commitments and optional Diplomat copying from public revealed defenses. Bureaucrat uses the public payee alliance to redirect enemy income or preserve allied income. These are legal prototype paths; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] }, + { area:'Documentation',status:'Partial',detail:'All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody; replacement entitlement and other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md','docs/PLANETOLOGIST_RULES.md','docs/SUK_GRADUATE_RULES.md','docs/LEADER_BATTLE_EFFECTS.md','docs/MENTAT_QUESTION.md','docs/BUREAUCRAT_PAYMENTS.md','docs/SMUGGLER_SHIPMENT.md','docs/SPICE_BANKER_RUNTIME.md','docs/SANDMASTER_WORM.md','docs/DIPLOMAT_DEFENSE.md'] }, + { area:'Verification',status:'Partial',detail:'Focused inventory, scoring, Voice, capture, saved private setup/revival Planetologist movement/cancellation and Suk Graduate casualty/SQLite checks, plus Rihani privacy/concurrent recovery battle-effect regressions and Smuggler shipment and battle collection, losing/captured survival, Sandmaster order, explosion, legacy and concurrent recovery, and Banker funding, death, traitor, privacy and concurrent settlement checks cover this boundary. Diplomat checks cover self-protection, mandatory discard, public-choice privacy, concurrent saved decisions and legacy continuation. Bureaucrat checks cover once-per-phase use, private source receipts and resumed payment custody. Sandmaster worm checks cover typed forces, decline, saved arrival reactions, concurrent writes, repeated distinct rides and special-Karama origin. Sample games and browser continuation are recorded separately; full module acceptance remains open.',evidence:['tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/leader-skills-recovery.test.ts','tests/leader-skills-controls.test.ts','tests/suk-graduate.test.ts','tests/suk-graduate-recovery.test.ts','tests/leader-battle-effects.test.ts','tests/leader-battle-effects-recovery.test.ts','tests/leader-battle-effects-controls.test.tsx','tests/mentat-question.test.ts','tests/mentat-question-bots.test.ts','tests/mentat-question-controls.test.tsx','tests/mentat-question-recovery.test.ts','tests/bureaucrat-payment.test.ts','tests/bureaucrat-payment-controls.test.tsx','tests/bureaucrat-payment-bots.test.ts','tests/bureaucrat-payment-recovery.test.ts','tests/smuggler-battle.test.ts','tests/smuggler-battle-recovery.test.ts','tests/smuggler-shipment.test.ts','tests/smuggler-shipment-controls.test.tsx','tests/smuggler-shipment-recovery.test.ts','tests/smuggler-no-field.test.ts','tests/smuggler-no-field-controls.test.tsx','tests/smuggler-no-field-recovery.test.ts','tests/spice-banker.test.ts','tests/spice-banker-recovery.test.ts','tests/spice-banker-controls.test.tsx','tests/sandmaster-movement.test.ts','tests/sandmaster-movement-recovery.test.ts','tests/sandmaster-movement-controls.test.tsx','tests/sandmaster-worm.test.ts','tests/sandmaster-worm-controls.test.tsx','tests/sandmaster-worm-recovery.test.ts','tests/diplomat-defense.test.ts','tests/diplomat-defense-controls.test.tsx','tests/diplomat-defense-recovery.test.ts'] }, ], }, { diff --git a/game/sandmaster-worm.ts b/game/sandmaster-worm.ts new file mode 100644 index 00000000..a93d3a92 --- /dev/null +++ b/game/sandmaster-worm.ts @@ -0,0 +1,49 @@ +import type { Game, GameView } from './engine'; +import { splitLocation, validGameLocation } from './board'; +import { faction } from './catalog'; +import { sandmasterLeader, sandmasterModeSupported } from './sandmaster-movement'; + +export type SandmasterWormCollection = { + leader: string; + key: string | null; + before: number; + blocked: string | null; +}; + +/** A worm ride names a destination, not a traversed ground route. */ +export function sandmasterWormCollection( + game: Game | GameView, + player: string, + destination: string, + sector: number, + decision: Game['decision'] | GameView['decision'] = game.decision, +): SandmasterWormCollection | null { + const leader = sandmasterLeader(game, player); + if (!leader || game.phase !== 1 || decision?.kind !== 'wormRide' || + decision.player !== player || + game.players.find(p => p.id === player)?.faction !== 'fremen') return null; + const blocked = (reason: string): SandmasterWormCollection => + ({ leader, key: null, before: 0, blocked: reason }); + if (!sandmasterModeSupported(game) || game.ecazTreachery || + game.players.some(p => faction(p.faction).expansion !== 'base')) + return blocked('Sandmaster worm collection with other optional modules is still being integrated.'); + if (destination === decision.territory || + !validGameLocation(game, destination, sector) || sector === game.storm) + return blocked('Choose a different legal destination for the worm ride.'); + const piles = Object.entries(game.spice) + .filter(([key]) => splitLocation(key).territory === destination); + if (piles.some(([key, amount]) => { + const at = splitLocation(key); + return !validGameLocation(game, at.territory, at.sector) || + key !== `${at.territory}:${at.sector}` || !Number.isSafeInteger(amount) || amount < 0; + })) return blocked('Sandmaster needs a valid destination spice pile.'); + const positive = piles.filter(([, amount]) => amount > 0); + if (positive.length > 1) + return blocked('Sandmaster collection among multiple spice piles awaits its allocation ruling.'); + if (!positive.length) return blocked('There is no spice to collect at this destination.'); + const [key, before] = positive[0]; + const spice = game.players.find(p => p.id === player)?.spice; + if (!Number.isSafeInteger(spice) || !Number.isSafeInteger(spice! + 1)) + return blocked('Sandmaster collection needs a valid spice balance.'); + return { leader, key, before, blocked: null }; +} diff --git a/tests/fixture-sandmaster-worm.ts b/tests/fixture-sandmaster-worm.ts new file mode 100644 index 00000000..dd84a2c3 --- /dev/null +++ b/tests/fixture-sandmaster-worm.ts @@ -0,0 +1,27 @@ +import { smugglerShipmentGame } from './smuggler-shipment-fixture'; +import type { Action } from '../game/engine'; + +/** Genuine Leader Skills setup, followed by a conserved post-Nexus ride position. */ +export function sandmasterWormGame(advanced = false, intrusion = false) { + const g = smugglerShipmentGame('fremen', advanced, 'sandmaster', intrusion ? 'beneGesserit' : 'guild'); + const own = g.players[0]; + own.name = 'Sandmaster rider'; + own.forces = { 'wind_pass:14': 2, 'wind_pass:15': 2 }; + own.reserves = 16; own.spice = 5; own.moved = 1; + if (own.elites) own.elites = { forces: { 'wind_pass:14': 1, 'wind_pass:15': 1 }, reserves: 1, tanks: 0, revived: 0 }; + Object.assign(g, { phase: 1, nexus: true, spiceSequence: { pile: 1, skipped: [] }, + wormRides: [], movementRemaining: null, decision: { kind: 'wormRide', player: 'p', territory: 'wind_pass' }, + spice: { 'wind_pass:14': 7, 'red_chasm:7': 2 } }); + if (intrusion) { + const bg = g.players[1]; + bg.forces = { 'red_chasm:7': 1 }; bg.reserves = 19; + bg.advisors = {}; + } + return g; +} +export function sandmasterRide(collect: boolean | undefined = true): Action { + return { type: 'decision', accept: true, territory: 'red_chasm', sector: 7, + forces: { 'wind_pass:14': 2, 'wind_pass:15': 2 }, + eliteForces: { 'wind_pass:14': 1, 'wind_pass:15': 1 }, + ...(collect === undefined ? {} : { sandmasterCollect: collect }) }; +} diff --git a/tests/sandmaster-worm-controls.test.tsx b/tests/sandmaster-worm-controls.test.tsx new file mode 100644 index 00000000..2c1ac27a --- /dev/null +++ b/tests/sandmaster-worm-controls.test.tsx @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { SandmasterWormChoice } from '../components/sandmaster-worm'; +import { sandmasterWormCollection } from '../game/sandmaster-worm'; +import { viewGame } from '../game/engine'; +import { sandmasterWormGame } from './fixture-sandmaster-worm'; + +void test('the ride control shows optional destination collection only for its eligible owner and preserves opt-out', () => { + const g = sandmasterWormGame(); + const quote = sandmasterWormCollection(viewGame(g, 'p'), 'p', 'red_chasm', 7); + const html = (collect: boolean) => renderToStaticMarkup(createElement(SandmasterWormChoice, { quote, collect, onChange() {} })); + assert.match(html(true), /checked=""/); + assert.doesNotMatch(html(false), /checked=""/); + assert.match(html(true), /Collect 1 spice/); + assert.match(html(true), /source and intervening territories earn nothing/); + assert.equal(renderToStaticMarkup(createElement(SandmasterWormChoice, { + quote: sandmasterWormCollection(viewGame(g, 'h'), 'h', 'red_chasm', 7), collect: true, onChange() {}, + })), ''); + g.spice['red_chasm:7'] = 0; + const blocked = renderToStaticMarkup(createElement(SandmasterWormChoice, { + quote: sandmasterWormCollection(viewGame(g, 'p'), 'p', 'red_chasm', 7), collect: true, onChange() {}, + })); + assert.doesNotMatch(blocked, /type="checkbox"/); + assert.match(blocked, /may still ride without collecting/); +}); diff --git a/tests/sandmaster-worm-recovery.test.ts b/tests/sandmaster-worm-recovery.test.ts new file mode 100644 index 00000000..f45f0a5e --- /dev/null +++ b/tests/sandmaster-worm-recovery.test.ts @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { DatabaseSync } from 'node:sqlite'; +import type { RoomsClock } from '../db/rooms'; +import { unitStore } from './fixture-nexus-room-store'; +import { sandmasterWormGame, sandmasterRide } from './fixture-sandmaster-worm'; + +const clock: RoomsClock = { now: () => 10000, sleep: async () => {} }; +void test('concurrent authenticated rides collect once, and SQLite restart resumes only the remaining BG arrival', async () => { + const f = unitStore(), sqlite: DatabaseSync = f.sqlite; + try { + const created = await f.rooms.createRoom('Rider', 'fremen', false, []); + const code = created.view.code; + const joined = await f.rooms.joinRoom(code, 'BG', 'beneGesserit'); + const tokens = [created.token!, joined.token!]; + for (const [i, token] of tokens.entries()) { + const auth = await f.rooms.authenticate(code, token); + sqlite.prepare('UPDATE seats SET player_id=? WHERE room_code=? AND player_id=?').run(i ? 'h' : 'p', code, auth.playerId); + } + const auth = await Promise.all(tokens.map(token => f.restart().authenticate(code, token))); + const state = sandmasterWormGame(true, true); + state.code = code; state.version = (await f.rooms.readRoom(code)).version; + state.spice['red_chasm:7'] = 1; + sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=?').run(JSON.stringify(state), state.version, code); + const seats = sqlite.prepare('SELECT * FROM seats WHERE room_code=? ORDER BY player_id').all(code); + const restored = await f.restart().readSeatView(code, auth[0]); + assert.equal(restored.decision?.kind, 'wormRide'); + assert.equal(restored.players[0].spice, 5); + let arrivals = 0; + let release!: () => void; + const barrier = new Promise(resolve => { release = resolve; }); + f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await barrier; }; + const action = sandmasterRide(); + const race = await Promise.allSettled([ + f.rooms.act(code, auth[0], state.version, action, clock), + f.restart().act(code, auth[0], state.version, action, clock), + ]); + delete f.hooks.beforeWrite; + assert.equal(arrivals, 2); + assert.equal(race.filter(r => r.status === 'fulfilled').length, 1); + assert.equal(race.filter(r => r.status === 'rejected').length, 1); + const pending = await f.restart().readRoom(code); + assert.equal(pending.version, state.version + 1); + assert.equal(pending.decision?.kind, 'intrusion'); + assert.equal(pending.players[0].spice, 6); + assert.equal(pending.spice['red_chasm:7'], 0); + assert.equal(pending.players[0].forces['red_chasm:7'], 4); + assert.equal(pending.players[0].elites!.forces['red_chasm:7'], 2); + for (const seat of auth) { + const view = await f.restart().readSeatView(code, seat); + assert.deepEqual(view, await f.rooms.readSeatView(code, seat)); + assert.equal('spice' in view.players.find(p => p.id !== view.me)!, false); + assert.equal('hand' in view.players.find(p => p.id !== view.me)!, false); + } + await assert.rejects(f.restart().act(code, auth[0], state.version, action, clock)); + await f.restart().act(code, auth[1], pending.version, { type: 'decision', accept: false }, clock); + const done = await f.restart().readRoom(code); + assert.equal(done.players[0].spice, 6); + assert.equal(done.spice['red_chasm:7'], 0); + assert.equal(done.players[0].moved, 1); + assert.equal(done.log.filter(l => l.automatic?.name === 'Sandmaster collection').length, 1); + await assert.rejects(f.restart().act(code, auth[0], done.version, action, clock)); + assert.deepEqual(await f.restart().readRoom(code), done); + assert.deepEqual(sqlite.prepare('SELECT * FROM seats WHERE room_code=? ORDER BY player_id').all(code), seats); + } finally { sqlite.close(); } +}); diff --git a/tests/sandmaster-worm.test.ts b/tests/sandmaster-worm.test.ts new file mode 100644 index 00000000..cce1553b --- /dev/null +++ b/tests/sandmaster-worm.test.ts @@ -0,0 +1,149 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, normalizeAutomaticGame, viewGame, type Game, type Action } from '../game/engine'; +import { sandmasterWormCollection } from '../game/sandmaster-worm'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { sandmasterWormGame, sandmasterRide } from './fixture-sandmaster-worm'; + +function reject(g: Game, action: Action) { + const before = structuredClone(g); + assert.throws(() => applyAction(g, 'p', action)); + assert.deepEqual(g, before); +} +function conserved(g: Game) { + for (const p of g.players) { + assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((s, n) => s + n, 0), 20); + if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((s, n) => s + n, 0), 3); + } +} +void test('Sandmaster collects once at a worm destination for multiple source sectors and preserves normal movement', () => { + for (const advanced of [false, true]) { + const g = sandmasterWormGame(advanced); + const action = sandmasterRide(); + if (!advanced) delete action.eliteForces; + const next = applyAction(JSON.parse(JSON.stringify(g)), 'p', action); + assert.equal(next.players[0].spice, 6); + assert.equal(next.spice['red_chasm:7'], 1); + assert.equal(next.spice['wind_pass:14'], 7); + assert.equal(next.players[0].forces['red_chasm:7'], 4); + assert.equal(next.players[0].moved, 1); + if (advanced) assert.equal(next.players[0].elites!.forces['red_chasm:7'], 2); + assert.equal(next.log.filter(l => l.automatic?.name === 'Sandmaster collection').length, 1); + conserved(next); + for (const p of next.players) assert.deepEqual(viewGame(next, p.id), viewGame(JSON.parse(JSON.stringify(next)), p.id)); + assert.equal(viewGame(next, 'h').players[0].spice, undefined); + reject(next, action); + } +}); +void test('optional decline, legacy actions and absent or ambiguous piles keep ordinary worm riding available', () => { + for (const amount of [0, 1, 2]) for (const collect of [false, undefined]) { + const g = sandmasterWormGame(); + g.spice['red_chasm:7'] = amount; + const action = sandmasterRide(false); delete action.eliteForces; + if (collect === undefined) delete action.sandmasterCollect; + const next = applyAction(g, 'p', action); + assert.equal(next.players[0].spice, 5); + assert.equal(next.spice['red_chasm:7'], amount); + assert.equal(next.players[0].forces['red_chasm:7'], 4); + } + const empty = sandmasterWormGame(); empty.spice['red_chasm:7'] = 0; + assert.match(sandmasterWormCollection(empty, 'p', 'red_chasm', 7)!.blocked!, /no spice/); + reject(empty, sandmasterRide()); + const multiple = sandmasterWormGame(); + multiple.spice = { 'pasty_mesa:5': 2, 'pasty_mesa:6': 3 }; + const ambiguous: Action = { ...sandmasterRide(), territory: 'pasty_mesa', sector: 5 }; + assert.match(sandmasterWormCollection(multiple, 'p', 'pasty_mesa', 5)!.blocked!, /multiple spice piles/); + reject(multiple, ambiguous); + delete ambiguous.eliteForces; + const next = applyAction(multiple, 'p', { ...ambiguous, sandmasterCollect: false }); + assert.equal(next.players[0].spice, 5); +}); +void test('failed force selection, storm entry and collecting without a ride cannot debit spice', () => { + const g = sandmasterWormGame(true); + for (const action of [ + { ...sandmasterRide(), forces: { 'wind_pass:14': 3 } }, + { ...sandmasterRide(), eliteForces: { 'wind_pass:14': 2, 'wind_pass:15': 1 } }, + { ...sandmasterRide(), forces: {} }, + { ...sandmasterRide(), accept: false }, + { ...sandmasterRide(), sandmasterCollect: 'yes' }, + { ...sandmasterRide(), territory: 'wind_pass', sector: 14 }, + ]) reject(g, action); + const storm = structuredClone(g); storm.storm = 7; reject(storm, sandmasterRide()); +}); +void test('Sandmaster requires the owned living native trainer and the supported public mode', () => { + const view = viewGame(sandmasterWormGame(), 'p'); + for (const mutate of [ + (g: typeof view) => { g.leaderSkills!.assignments.find(a => a.owner === 'p')!.faceUp = false; }, + (g: typeof view) => { g.leaderSkills!.assignments.find(a => a.owner === 'p')!.controller = 'h'; }, + (g: typeof view) => { g.players[0].leaders.find(l => l.id === g.leaderSkills!.assignments.find(a => a.owner === 'p')!.leader)!.dead = true; }, + ]) { + const bad = structuredClone(view); mutate(bad); + assert.equal(sandmasterWormCollection(bad, 'p', 'red_chasm', 7), null); + } + for (const mutate of [ + (g: typeof view) => { g.ecazTreachery = true; }, + (g: typeof view) => { g.players[1].faction = 'richese'; }, + ]) { + const unsupported = structuredClone(view); mutate(unsupported); + assert.match(sandmasterWormCollection(unsupported, 'p', 'red_chasm', 7)!.blocked!, /other optional modules/); + } +}); +void test('collection is committed before an interrupted BG arrival and is not repeated after JSON restoration', () => { + const g = sandmasterWormGame(true, true); + g.spice['red_chasm:7'] = 1; + const pending = applyAction(g, 'p', sandmasterRide()); + assert.equal(pending.decision?.kind, 'intrusion'); + assert.equal(pending.players[0].spice, 6); + assert.equal(pending.spice['red_chasm:7'], 0); + const restored = normalizeAutomaticGame(JSON.parse(JSON.stringify(pending))); + assert.deepEqual(restored, pending); + const done = applyAction(restored, 'h', { type: 'decision', accept: false }); + assert.equal(done.players[0].spice, 6); + assert.equal(done.log.filter(l => l.automatic?.name === 'Sandmaster collection').length, 1); + assert.equal(done.players[0].moved, 1); + conserved(done); +}); +void test('all profiles attach collection to legal rides without changing the existing destination policy', () => { + for (const difficulty of DIFFICULTIES) { + const g = sandmasterWormGame(true); + const view = viewGame(g, 'p'); view.players[0].bot = difficulty; + const actions = botActions(view); + const collecting = actions.filter(a => a.sandmasterCollect); + assert.ok(collecting.length, difficulty); + for (const action of collecting) { + const next = applyAction(g, 'p', action); + assert.equal(next.players[0].spice, 6); conserved(next); + } + assert.ok(actions.some(a => a.accept === false)); + } +}); +void test('separate queued rides each collect once after the previous arrival finishes', () => { + let g = sandmasterWormGame(true, true); + g.wormRides = ['wind_pass']; + const action = { ...sandmasterRide(), forces: { 'wind_pass:14': 1, 'wind_pass:15': 1 } }; + g = applyAction(g, 'p', action); + assert.equal(g.players[0].spice, 6); + g = applyAction(JSON.parse(JSON.stringify(g)), 'h', { type: 'decision', accept: false }); + assert.equal(g.decision?.kind, 'wormRide'); + g = applyAction(g, 'p', { ...action, eliteForces: { 'wind_pass:14': 0, 'wind_pass:15': 0 } }); + assert.equal(g.players[0].spice, 7); + assert.equal(g.spice['red_chasm:7'], 0); + g = applyAction(JSON.parse(JSON.stringify(g)), 'h', { type: 'decision', accept: false }); + assert.equal(g.log.filter(l => l.automatic?.name === 'Sandmaster collection').length, 2); + assert.equal(g.players[0].moved, 1); conserved(g); +}); +void test('a genuine special-Karama summoned worm resumes through Nexus and collects only on its accepted ride', () => { + let g = sandmasterWormGame(true); + g.decision = null; g.nexus = false; + const index = g.deck.findIndex(c => c.effect === 'karama'); assert.ok(index >= 0); + const [card] = g.deck.splice(index, 1); g.players[0].hand.push(card); + g = applyAction(g, 'p', { type: 'card', card: card.id, mode: 'special', territory: 'wind_pass' }); + while (g.response) g = applyAction(g, g.players.find(p => !g.response!.passed.includes(p.id))!.id, { type: 'passResponse' }); + assert.equal(g.players[0].spice, 5); + for (const p of g.players) if (!g.ready.includes(p.id)) g = applyAction(g, p.id, { type: 'ready' }); + assert.equal(g.decision?.kind, 'wormRide'); + g = applyAction(JSON.parse(JSON.stringify(g)), 'p', sandmasterRide()); + assert.equal(g.players[0].spice, 6); + assert.equal(g.players[0].moved, 1); conserved(g); +}); diff --git a/tests/smuggler-shipment-fixture.ts b/tests/smuggler-shipment-fixture.ts index caaf6301..aab1fb9f 100644 --- a/tests/smuggler-shipment-fixture.ts +++ b/tests/smuggler-shipment-fixture.ts @@ -18,11 +18,12 @@ export function smugglerShipmentGame( faction: FactionId = 'emperor', advanced = false, skill: 'smuggler' | 'sandmaster' = 'smuggler', + opponentFaction?: FactionId, ): Game { let g = createGame('SMUGSHIP', newPlayer('p', 'Smuggler', faction), advanced); joinGame( g, - newPlayer('h', 'Opponent', faction === 'guild' ? 'emperor' : 'guild'), + newPlayer('h', 'Opponent', opponentFaction ?? (faction === 'guild' ? 'emperor' : 'guild')), ); for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); const index = LEADER_SKILL_CARDS.findIndex((c) => c.id === skill); @@ -54,7 +55,7 @@ export function smugglerShipmentGame( : g.setupStage === 'traitors' && p.traitorChoices.length ? [{ type: 'traitor', leader: p.traitorChoices[0] }] : g.setupStage === 'prediction' && p.faction === 'beneGesserit' - ? [{ type: 'predict', faction: g.players[1].faction, turn: 3 }] + ? [{ type: 'predict', faction: g.players.find(other => other.id !== p.id)!.faction, turn: 3 }] : g.setupStage === 'forces' && p.faction === 'fremen' && p.reserves === 20 @@ -114,7 +115,8 @@ export function smugglerShipmentGame( p.shipped = false; p.moved = 0; p.advisors = {}; - if (p.elites) p.elites = { ...p.elites, forces: {}, reserves: 5, tanks: 0 }; + if (p.elites) p.elites = { ...p.elites, forces: {}, + reserves: p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0), tanks: 0 }; } return g; }