From 0040ed192183ce5723b1b775287335aad5fe7d8b Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:51:12 +1100 Subject: [PATCH] Connect owned Sandmaster destination pile choices --- components/ambassador-movement.tsx | 45 +++++++++- components/game-table.tsx | 15 +++- components/sandmaster-worm.tsx | 20 ++++- docs/CURRENT_STATUS.md | 14 +++ docs/IMPLEMENTATION_STATUS.md | 30 +++++++ docs/RULE_DECISIONS.md | 2 +- docs/SANDMASTER_MOVEMENT.md | 47 +++++++++- docs/SANDMASTER_WORM.md | 75 +++++++++++----- game/bots.ts | 17 ++-- game/engine.ts | 17 +++- game/fremen-ambassador.ts | 9 +- game/reference.ts | 2 +- game/sandmaster-movement.ts | 18 ++-- game/sandmaster-worm.ts | 5 +- tests/fremen-ambassador-engine.test.ts | 114 ++++++++++++++++++++++++ tests/sandmaster-worm-controls.test.tsx | 20 ++--- tests/sandmaster-worm.test.ts | 97 +++++++++++++++----- 17 files changed, 458 insertions(+), 89 deletions(-) diff --git a/components/ambassador-movement.tsx b/components/ambassador-movement.tsx index fad0b6e5..22301804 100644 --- a/components/ambassador-movement.tsx +++ b/components/ambassador-movement.tsx @@ -31,6 +31,11 @@ export function AmbassadorMovement({ const [takeMarker, setTakeMarker] = useState(false); const [fighters, setFighters] = useState(false); const [collectSandmaster, setCollectSandmaster] = useState(true); + const [sandmasterSelection, setSandmasterSelection] = useState<{ + source: string; + target: string; + key: string; + } | null>(null); const entry = game.ambassadorEntry; if ( entry?.stage !== 'move' || @@ -83,6 +88,12 @@ export function AmbassadorMovement({ const sandmaster = !includesMarker && destination ? sandmasterDestinationCollection(game, game.me, source.territory, destination.territory, destination.sector) : null; + const sandmasterPile = + sandmasterSelection?.source === source.territory && + sandmasterSelection.target === target && + sandmaster?.piles.some((pile) => pile.key === sandmasterSelection.key) + ? sandmasterSelection.key + : sandmaster?.key; const reason = destination?.blocked ?? (!destination @@ -98,6 +109,7 @@ export function AmbassadorMovement({ setForces((old) => ({ ...old, [key]: 0 })); setElites((old) => ({ ...old, [key]: 0 })); setFighters(false); + setSandmasterSelection(null); }; const targetTerritories = [...new Set(destinations.map((d) => d.territory))]; return ( @@ -123,6 +135,7 @@ export function AmbassadorMovement({ setElites({}); setTakeMarker(false); setFighters(false); + setSandmasterSelection(null); }} > {sources.map((s) => ( @@ -286,6 +299,31 @@ export function AmbassadorMovement({ {sandmaster.blocked ?? 'Collect 1 spice here with Sandmaster before arrival reactions'} )} + {sandmaster && !sandmaster.blocked && sandmaster.piles.length > 1 && ( + + )} + {sandmaster && !sandmaster.blocked && sandmaster.piles.length > 1 && ( +

You choose the one-spice debit sector. This provisional allocation policy is not a publisher ruling.

+ )}

Selected: {total} physical forces {includesMarker ? ' and one concealed No-Field' : ''}. @@ -317,7 +355,12 @@ export function AmbassadorMovement({ } : {}), ...(fighters && destination.canFight ? { fighters: true } : {}), - ...(sandmaster?.blocked === null ? { sandmasterCollect: collectSandmaster } : {}), + ...(sandmaster?.blocked === null + ? { + sandmasterCollect: collectSandmaster, + ...(collectSandmaster && sandmasterPile ? { sandmasterPile } : {}), + } + : {}), }); }} > diff --git a/components/game-table.tsx b/components/game-table.tsx index 6b13af26..b7324192 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -493,7 +493,13 @@ export function GameTable({ const [moveForces, setMoveForces] = useState>({}); const [rideForces, setRideForces] = useState>({}); const [collectSandmasterRide, setCollectSandmasterRide] = useState(true); - const sandmasterRide = sandmasterWormCollection(g, me.id, selected, sector); + 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 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 [exchangeCards, setExchangeCards] = useState>( {}, ); @@ -3646,10 +3652,13 @@ export function GameTable({ /> ))} + onChange={setCollectSandmasterRide} + onPileChange={key => setSandmasterRidePile({ target: sandmasterRideTarget, key })} /> {actionButton('Ride Shai-Hulud', { type: 'decision', - ...(sandmasterRide?.blocked === null ? { sandmasterCollect: collectSandmasterRide } : {}), + ...(sandmasterRide?.blocked === null + ? { sandmasterCollect: collectSandmasterRide, + ...(collectSandmasterRide ? { sandmasterPile: sandmasterRide.key } : {}) } : {}), eliteForces, accept: true, territory: selected, diff --git a/components/sandmaster-worm.tsx b/components/sandmaster-worm.tsx index 581e78ab..137a021b 100644 --- a/components/sandmaster-worm.tsx +++ b/components/sandmaster-worm.tsx @@ -1,17 +1,31 @@ +import { splitLocation } from '@/game/board'; import type { SandmasterDestinationCollection } from '@/game/sandmaster-movement'; -export function SandmasterWormChoice({ quote, collect, onChange }: { +export function SandmasterWormChoice({ quote, collect, onChange, onPileChange }: { quote: SandmasterDestinationCollection | null; collect: boolean; onChange: (collect: boolean) => void; + onPileChange: (key: string) => void; }) { if (!quote) return null; - return

+ return
{quote.blocked ?

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

: <> - + {quote.piles.length > 1 && <> + +

Provisional multi-pile policy: you choose which sector supplies + the one spice; this is not a publisher allocation ruling.

+ } +

Sector {splitLocation(quote.key!).sector}: {quote.before} → {quote.before - 1} spice if collected.

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 771f9ec5..d318ecdb 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,20 @@ optional skill bands, combinations and public expansion starts remain open. Rules implementation continues; comprehensive assurance and administration remain deferred until all rules are implemented. +**Owned Sandmaster destination piles — 9 October.** Native worm rides and +physical Fremen Ambassador relocation now select an exact positive pile +when several exist. Collection remains optional and capped at one spice; +invalid/omitted ambiguous keys reject before transfer. Stored Ambassador +reconstruction retains the selected key. Basic/Advanced native smokes and +all four existing legal policies pass without destination-strategy changes. +At390px, actual worm controls debit Pasty Mesa6 while landing four forces +in Mesa5 (spice5→6); Ambassador controls reset on a destination change and +debit South Mesa5 while landing two forces in Mesa4 (spice20→21). +Types/lint and 91 affected cases pass. HMS multi-pile controls remain open. +See [worm](SANDMASTER_WORM.md#connected-behavior) and +[physical Ambassador](SANDMASTER_MOVEMENT.md#physical-ambassador-owned-destination-allocation--9-october-2026). + + **Owned Sandmaster ground-pile choice — 9 October.** Ordinary route collection now offers every positive pile, but permits only one selected sector per entered territory. The checkbox group supports switching piles diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index ae3abd9c..0d5e8b0e 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,35 @@ # Dune implementation status +## 9 October 2026 — owned worm and physical Ambassador destination piles + +The shared destination quote exposes all legal positive piles and an exact +selection. Native worm and physical Fremen Ambassador declarations add +`sandmasterPile` when collecting; ambiguity requires a supplied key. +Single-pile boolean declarations remain valid. One ride/relocation earns +only one spice, regardless of forces or piles, before arrival reactions. +Owner allocation is visibly provisional, not publisher adjudication. + +The actual UI selectors reset stale destination selection, retain optional +opt-out and show before/after quantities. Existing native/profile, +same-territory and concealed-marker guards stay. Stored Ambassador movement +reconstructs its exact frozen key through the original receipt. + +Basic/Advanced native smoke chooses Pasty Mesa6 (3→2), preserves Mesa5 at2, +lands four forces in Mesa5 and credits5→6 without changing normal movement. +Physical Ambassador smoke chooses South Mesa5 (2→1), preserves Mesa4 at4, +lands two forces in Mesa4 and credits20→21 with moved0/shippedfalse. +All four existing policies retain legal continuation and send explicit keys +when they choose collection; optional destination strategy is unchanged. +Isolated 390px production controls reproduce both results, including +Ambassador destination reset and opt-out/reselection. No existing game store +or deployed acceptance is claimed. + +`check:quick` passes; 91 affected native, quote, control, bot, cancellation +and movement cases pass. The selected-pile Intrusion/JSON cases settle once. +A duplicate control case that enabled an undealt card variant was removed; +existing valid profile guards remain covered. Temporary scripts and the +dedicated service were removed. No broad recovery/HTTP campaign or deployment. + ## 9 October 2026 — owned Sandmaster ground-movement pile selection The shared ground quote now groups all positive pile keys by entered diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index bdd18fdb..bde2f9a5 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -21,7 +21,7 @@ They supersede earlier software-absence claims only within their named scope. | Full-hand Nullentropy Box | Basic and Advanced permit an atomic retrieval/Box-disposal exchange with final hand within its actual limit; Box remains last/on top. Intermediate capacity is not adjudicated. | [Box runtime](NULLENTROPY_BOX_ENGINE_AUDIT.md) | | Kulon with Richese Ornithopter | Both modes coexist in Basic/Advanced. The fixed one-group card maximum remains three; Kulon extends each normal-range group. Explicitly provisional, not a publisher ruling. | [Movement policy](RICHESE_ORNITHOPTER_ENGINE_AUDIT.md#provisional-kulon-coexistence--9-october-2026) | | Multi-pile battle skills | In both modes, surviving Smuggler allocates the fixed unmodified-strength-capped collection among reveal-time positive piles; Sandmaster winner then selects one remaining pile for +3. Single-pile/all/none/void effects stay automatic. Owner sector selection is provisional, not a publisher allocation ruling. | [Smuggler](SMUGGLER_BATTLE.md#multi-pile-allocation-policy-and-pure-api--9-october-2026), [Sandmaster](LEADER_BATTLE_EFFECTS.md#source-contract) | -| Multi-pile Sandmaster ground movement | The owner chooses one existing positive debit sector per entered territory, or declines its one-spice collection. Duplicate territory collection rejects. Worm/Ambassador destination and HMS multi-pile paths remain separate. Explicitly provisional, not a publisher allocation ruling. | [Ground-route policy](SANDMASTER_MOVEMENT.md#owned-ground-route-pile-selection--9-october-2026) | +| Multi-pile Sandmaster movement | Ground movement chooses one existing positive debit sector per entered territory; worm and physical Fremen Ambassador relocation choose one at their entered destination, or decline collection. Duplicate territory collection rejects; destination multi-pile requests need an explicit key. HMS multi-pile controls remain separate. Provisional owner allocation, not a publisher ruling. | [Ground](SANDMASTER_MOVEMENT.md#owned-ground-route-pile-selection--9-october-2026), [Ambassador](SANDMASTER_MOVEMENT.md#physical-ambassador-owned-destination-allocation--9-october-2026), [worm](SANDMASTER_WORM.md#connected-behavior) | | Basic occupied Homeworld lifecycle | Current sole foreign garrison controls occupied income, Bidding effects, defenses, percentage shares and the Ecaz-victory holding; the entitlement ends immediately on departure, contest or native repopulation. A recorded source observation is still required. Basic Tupile slot leasing keeps the conservative original-epoch gate. Visibly provisional, not a publisher or numerical user ruling. | [Occupation](HOMEWORLD_OCCUPATION_RULES.md#basic-current-sole-benefit-policy--9-october-2026) | No publication or faction/mode gate is opened. The exact 7 October user diff --git a/docs/SANDMASTER_MOVEMENT.md b/docs/SANDMASTER_MOVEMENT.md index c408e1e5..fa76a4f4 100644 --- a/docs/SANDMASTER_MOVEMENT.md +++ b/docs/SANDMASTER_MOVEMENT.md @@ -28,11 +28,50 @@ declines/reselects the territory, and commits the same move: personal spice10→12, South Mesa4 remains4, South Mesa5 becomes1, Cielago East3 becomes3, and three forces arrive in Cielago East. -Worm/physical-Ambassador destination choices and native HMS relocation -retain their separate single-pile scope. No public start or broader -source adjudication is opened by this ground-route change. +Destination relocation choices are separate from these ground-route controls; +physical Fremen Ambassador allocation is recorded below and worm collection +in [its own contract](SANDMASTER_WORM.md). Native HMS relocation retains its +single-pile scope. No public start or broader source adjudication is opened +by this ground-route change. +## Physical Ambassador owned-destination allocation — 9 October 2026 + +Physical Fremen Ambassador relocation now offers every existing positive +destination-territory pile and collects at most one spice from exactly one +chosen sector before physical arrival and its reactions. The checkbox remains +optional. With multiple legal piles, the real relocation control shows their +sector quantities as before→before−1, defaults to the first legal pile, and +allows another sector. Changing source or destination resets the selection; +an unavailable selection cannot strand the draft. Declining sends no pile key +and transfers no spice. + +`sandmasterCollect: true` with multiple positive piles requires an explicit +`sandmasterPile` key. A single-pile boolean declaration remains valid. Native +validation rejects missing, malformed, foreign, unknown or exhausted selections +before force or spice mutation. The shared `sandmasterDestinationCollection` +quote supplies the legal piles and the selected quantity; `ambassadorSandmaster` +retains the exact native trainer, key and original quantity as its frozen proof. +Stored continuation revalidates that same key, not a fresh default; a committed +arrival interruption resumes without collecting twice. + +Choosing the debit sector is **provisional owner allocation**, not a publisher +ruling: the source grants one spice for entering the territory but does not +name its sector. Same-territory moves, living-native skill eligibility, +concealed-marker restrictions, typed forces, ordinary allowances and the +original Ambassador event remain unchanged. This does not change ground-route +or native HMS eligibility, add spice or invent intervening sectors. + +Focused native-engine cases now cover Basic/Advanced alternate-pile allocation, +decline, invalid declarations without mutation, living-trainer restrictions and +collection before a JSON-restored Intrusion continuation. Integrated native +smoke in both modes chooses South Mesa5 (2→1), preserves Mesa4 at4, +lands two forces in Mesa4 and credits spice20→21 without ordinary +shipment/movement use. At390px, the actual control changes destination, +resets the pile selection, opts out/reselects and commits the same result. +The 8 October rejection evidence below remains a dated checkpoint, not +the current multi-pile policy. + ## Physical Fremen Ambassador relocation — 8 October 2026 The Ambassador expressly relocates a board force group. The normal @@ -129,7 +168,7 @@ Broad checkpoint results are recorded with the checkpoint. These checks do not c The [worm-ride follow-up](SANDMASTER_WORM.md) connects native Fremen destination collection, including saved arrival reactions. Physical Fremen Ambassador relocation is connected above. Other nonordinary relocations, special cards, -concealed-marker collection, multi-pile destination/HMS collection and wider +concealed-marker collection, multi-pile HMS collection and wider interaction/strategy acceptance remain. The lower battle band is in [the battle-effects contract](LEADER_BATTLE_EFFECTS.md). diff --git a/docs/SANDMASTER_WORM.md b/docs/SANDMASTER_WORM.md index d410b13d..4263b622 100644 --- a/docs/SANDMASTER_WORM.md +++ b/docs/SANDMASTER_WORM.md @@ -24,35 +24,68 @@ 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. +A shared public quote validates the player, destination, skill and all positive +destination piles. Collection remains optional and capped at one spice for the +entire ride. With several valid piles, the owner chooses the exact sector supplying +that spice; placement of the riding forces does not constrain the chosen pile. +This allocation policy is **provisional**, not a publisher sector-allocation ruling. +It does not invent a pile, an intermediate route or extra normal movement. +Multi-sector and typed elite selections still transfer one spice total, not one +per force, source sector or destination pile. + +The checkbox defaults to collection but permits declining it. A multi-pile +selector is controlled by the parent's selected quote key and shows each sector's +before → before − 1 quantity. A single pile needs no selector; an empty destination +or unsupported quote has no collection checkbox. None of these cases prevents +ordinary riding without collection, and there is no extra confirmation. + +`sandmasterWormCollection(game, player, destination, sector, decision = game.decision, +selectedPile?: string)` retains its native Fremen, phase, decision, leader and +configuration guards. Its quote includes `piles: Array<{ key: string; before: number }>`, +the selected `key` and `before`, and any `blocked` reason. Without `selectedPile`, +the quote offers the first legal pile; this UI default is not action authorization. +An invalid selected key blocks the quote, and blocked quotes expose `piles: []`. + +An accepted worm-ride action may set `sandmasterCollect: true` and +`sandmasterPile: "territory:sector"`. Collection from multiple positive destination +piles requires that explicit key; omitting it rejects the action without mutation. +Single-pile collection still accepts the legacy boolean-only action. Send the pile +field only when collecting. Declining collection or omitting both fields preserves +ordinary riding and leaves board/player spice unchanged. + +`SandmasterWormChoice` receives `quote`, `collect`, `onChange(collect)` and the +required `onPileChange(key)` callback. The parent owns selection state, requotes +that exact key and includes it in the collecting action. 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. +All four AI profiles attach the optional collection flag and exact quoted pile key +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. +The focused suites include Basic/Advanced force custody, source exclusion, decline, +omitted legacy fields, empty destinations, explicit allocation between two positive +piles, ambiguous or invalid requests without mutation, native trainer +ownership/visibility, public mode guards, private views and JSON restoration during +a Bene Gesserit reaction. The selected pile loses only one spice, other piles remain +unchanged, and a pending arrival cannot collect a second time. 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. Controls retain eligible-owner and blocked-state coverage rather +than pinning selector wording, HTML order or callback echoes. -Other expansion factions/modules, the Ecaz card variant and multiple positive -piles remain guarded. Other special relocation, full Leader Skills integration, +Integrated native smoke in both modes collects exactly one from selected +Pasty Mesa6 (3→2), leaves Mesa5 at2 and lands four forces in Mesa5 without +changing normal movement. An isolated 390px production control selects +Mesa6 and observes the same live-engine result, personal spice5→6. +All four existing policies retain legal continuation; collecting declarations +carry explicit pile keys without changing destination strategy. + +Other expansion factions/modules and the Ecaz card variant 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 0bf651d8..6074fb0c 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -409,6 +409,8 @@ function fremenAmbassadorMoves(g: GameView): Action[] { (presenceAt(me, destination.territory) ? 8 : 0) - (abandons ? 25 : 0) - (destination.advisors ? 3 : 0); + const sandmaster = !marker + ? sandmasterDestinationCollection(g, me.id, source.territory, destination.territory, destination.sector) : null; candidates.push({ score, action: { @@ -418,8 +420,8 @@ function fremenAmbassadorMoves(g: GameView): Action[] { eliteForces, territory: destination.territory, sector: destination.sector, - ...(!marker && sandmasterDestinationCollection(g, me.id, source.territory, destination.territory, destination.sector)?.blocked === null - ? { sandmasterCollect: true } : {}), + ...(sandmaster?.blocked === null + ? { sandmasterCollect: true, sandmasterPile: sandmaster.key } : {}), ...(marker ? { noField: { tokenId: marker.tokenId, event: marker.event } } : {}), @@ -2805,13 +2807,15 @@ function policyActions(g: GameView): Action[] { ) .slice(0, 16) .map( - (to): Action => ({ + (to): Action => { + const sandmaster = sandmasterWormCollection(g, me.id, to.t, to.s); + return { type: 'decision', accept: true, territory: to.t, sector: to.s, - ...(sandmasterWormCollection(g, me.id, to.t, to.s)?.blocked === null - ? { sandmasterCollect: true } : {}), + ...(sandmaster?.blocked === null + ? { sandmasterCollect: true, sandmasterPile: sandmaster.key } : {}), forces: Object.fromEntries( Object.entries(me.forces).filter( ([key]) => @@ -2819,7 +2823,8 @@ function policyActions(g: GameView): Action[] { splitLocation(key).sector !== g.storm, ), ), - }), + }; + }, ), { type: 'decision', accept: false }, ]; diff --git a/game/engine.ts b/game/engine.ts index 7104359f..b4b927d6 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -14162,7 +14162,8 @@ function validateMovementOrder(g: Game, p: Player, move: MovementOrder) { territory: move.to, sector: move.sector, fighters: move.wantsFighters, - ...(move.ambassadorSandmaster ? { sandmasterCollect: true } : {}), + ...(move.ambassadorSandmaster + ? { sandmasterCollect: true, sandmasterPile: move.ambassadorSandmaster.key } : {}), ...(move.noField ? { noField: { @@ -29775,6 +29776,9 @@ function applyActionInner( ); 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 Sandmaster destination pile only when collecting.'); requireRule(action.accept || !action.sandmasterCollect, 'Sandmaster collection requires an accepted worm ride.'); if (action.accept) { @@ -29785,9 +29789,14 @@ 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.'); + const sandmaster = action.sandmasterCollect + ? sandmasterWormCollection(g, p.id, to, sector, decision, action.sandmasterPile as string | undefined) : 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.'); + } requireRule( action.forces && typeof action.forces === 'object' && diff --git a/game/fremen-ambassador.ts b/game/fremen-ambassador.ts index 351564b8..720a7ff5 100644 --- a/game/fremen-ambassador.ts +++ b/game/fremen-ambassador.ts @@ -330,13 +330,20 @@ export function quoteFremenAmbassadorMove( ); requireMove(action.sandmasterCollect === undefined || typeof action.sandmasterCollect === 'boolean', 'Choose whether to collect spice with Sandmaster.'); + requireMove(action.sandmasterPile === undefined || + (action.sandmasterCollect === true && typeof action.sandmasterPile === 'string'), + 'Choose a Sandmaster spice pile only when collecting.'); let ambassadorSandmaster: FremenAmbassadorMove['ambassadorSandmaster']; if (action.sandmasterCollect) { requireMove(physical > 0 && !noField, 'Sandmaster collection with concealed Ambassador relocation is still being integrated.'); - const quote = sandmasterDestinationCollection(g, p.id, origin, to, sector); + const quote = sandmasterDestinationCollection( + g, p.id, origin, to, sector, action.sandmasterPile, + ); requireMove(quote && !quote.blocked && quote.key, quote?.blocked ?? 'Sandmaster collection needs the living native trainer.'); + requireMove(quote.piles.length <= 1 || typeof action.sandmasterPile === 'string', + 'Choose the destination spice pile for Sandmaster collection.'); ambassadorSandmaster = { leader: quote.leader, key: quote.key, before: quote.before }; } return { diff --git a/game/reference.ts b/game/reference.ts index c415fed1..2aa1f317 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4730,7 +4730,7 @@ export const RULE_TOPICS: RuleTopic[] = [ '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. Rescued counters still count toward Advanced Atreides’ seven battle losses for Kwisatz Haderach; combined modules remain unfinished.', 'In the supported classic Nexus/Skills composition, Emperor Cunning counts five actual ordinary counters as temporary Sardaukar. Normal or trained Suk rescue still saves physical ordinary casualties. The original casualty allocation remains pending while the rescue choice is open; physical counters move only when rescue settles. Original support is already paid and is not refunded or charged twice. A normal save returns one to reserves; a trained save keeps one and returns the rest. Actual starred reserves and Tanks remain unchanged.', '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. With multiple positive piles, provisionally choose one debit sector per territory, or decline collection; two piles never grant two spice from the same territory. 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 arrival reactions, without a ground route or normal movement use. Native Ixian HMS interior passengers count as entering the territories it points into and collect before faction spice. Multi-pile worm/Ambassador destination and HMS collection, and other special relocation, remain separate unfinished paths.', + 'A living native Sandmaster may collect one spice per entered territory along a declared legal ground route. With multiple positive piles, provisionally choose one debit sector per territory, or decline collection; two piles never grant two spice from the same territory. Canceled moves earn nothing. The initial source and a sector-only reposition earn nothing. Separate movements have separate collection opportunities. Native Fremen worm rides and physical Fremen Ambassador relocation likewise offer one selected destination pile before arrival reactions, without an invented ground route or normal movement use. These destination allocations are provisional. Native Ixian HMS passengers collect before faction spice, but its multi-pile controls and other special relocation remain unfinished.', 'A surviving skilled Sandmaster winner adds three board spice to an existing pile in the battle territory, even with no surviving forces. No pile adds nothing; a single pile resolves automatically. With several remaining positive piles, the winner selects one sector after any Smuggler allocation. Owner selection is a visibly provisional placement policy, not a publisher 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.', diff --git a/game/sandmaster-movement.ts b/game/sandmaster-movement.ts index f5e4244f..c941674c 100644 --- a/game/sandmaster-movement.ts +++ b/game/sandmaster-movement.ts @@ -60,6 +60,7 @@ export type SandmasterDestinationCollection = { leader: string; key: string | null; before: number; + piles: { key: string; before: number }[]; blocked: string | null; }; @@ -70,18 +71,18 @@ export function sandmasterDestinationCollection( origin: string, destination: string, sector: number, + selectedPile?: string, ): SandmasterDestinationCollection | null { const leader = sandmasterLeader(game, player); if (!leader) return null; const blocked = (reason: string): SandmasterDestinationCollection => - ({ leader, key: null, before: 0, blocked: reason }); + ({ leader, key: null, before: 0, piles: [], blocked: reason }); if (!sandmasterModeSupported(game)) return blocked('Sandmaster collection with this configuration is still being integrated.'); if (origin === destination || !validGameLocation(game, destination, sector) || sector === game.storm) return blocked('Choose a different legal destination territory for Sandmaster collection.'); - let key: string | null = null; - let before = 0; + const piles: { key: string; before: number }[] = []; for (const pile in game.spice) { const at = splitLocation(pile); if (at.territory !== destination) continue; @@ -91,15 +92,16 @@ export function sandmasterDestinationCollection( !Number.isSafeInteger(amount) || amount < 0) return blocked('Sandmaster needs a valid destination spice pile.'); if (!amount) continue; - if (key) return blocked('Sandmaster collection among multiple spice piles awaits its allocation ruling.'); - key = pile; - before = amount; + piles.push({ key: pile, before: amount }); } - if (!key) return blocked('There is no spice to collect at this destination.'); + if (!piles.length) return blocked('There is no spice to collect at this destination.'); + piles.sort((a, b) => a.key.localeCompare(b.key)); + const chosen = selectedPile === undefined ? piles[0] : piles.find(pile => pile.key === selectedPile); + if (!chosen) return blocked('Choose an existing positive spice pile at this destination.'); 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 }; + return { leader, key: chosen.key, before: chosen.before, piles, blocked: null }; } export function sandmasterPathBlocked( diff --git a/game/sandmaster-worm.ts b/game/sandmaster-worm.ts index ebf4e7bd..91fd96f3 100644 --- a/game/sandmaster-worm.ts +++ b/game/sandmaster-worm.ts @@ -11,16 +11,17 @@ export function sandmasterWormCollection( destination: string, sector: number, decision: Game['decision'] | GameView['decision'] = game.decision, + selectedPile?: string, ): SandmasterDestinationCollection | 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): SandmasterDestinationCollection => - ({ leader, key: null, before: 0, blocked: reason }); + ({ leader, key: null, before: 0, piles: [], blocked: reason }); if (!sandmasterModeSupported(game) || game.ecazTreachery || !(nativeExpansionLeaderSkillsProfile(game) || game.players.every(p => faction(p.faction).expansion === 'base'))) return blocked('Sandmaster worm collection with other optional modules is still being integrated.'); - return sandmasterDestinationCollection(game, player, decision.territory, destination, sector); + return sandmasterDestinationCollection(game, player, decision.territory, destination, sector, selectedPile); } diff --git a/tests/fremen-ambassador-engine.test.ts b/tests/fremen-ambassador-engine.test.ts index e45f3bc8..79f9f90d 100644 --- a/tests/fremen-ambassador-engine.test.ts +++ b/tests/fremen-ambassador-engine.test.ts @@ -146,6 +146,19 @@ function trigger(g: Game, beneficiary = 'a', copy = false) { return out; } +function sandmasterFixture(advanced = true, extra: FactionId[] = []) { + const g = fixture('fremen', extra); + g.advanced = advanced; + g.expansions = ['ecaz']; + army(g, 'a', { 'red_chasm:7': 2 }); + g.spice = { 'south_mesa:4': 4, 'south_mesa:5': 2 }; + const skills = createLeaderSkills(() => 0.2); + skills.deck = skills.deck.filter(skill => skill !== 'sandmaster'); + skills.assignments = [{ skill: 'sandmaster', owner: 'a', leader: p(g, 'a').leaders[0].id }]; + g.leaderSkills = skills; + return g; +} + void test('physical Fremen Ambassador relocation collects optional Sandmaster spice without consuming ordinary allowances', () => { for (const collect of [false, true]) { const start = fixture(); @@ -171,6 +184,107 @@ void test('physical Fremen Ambassador relocation collects optional Sandmaster sp assert.equal(done.pendingAmbassador, null); } }); + +void test('Basic and Advanced Ambassador relocation debit exactly the chosen destination pile or decline both', () => { + for (const advanced of [false, true]) { + const pending = trigger(enter(sandmasterFixture(advanced))); + for (const selected of [undefined, 'south_mesa:4', 'south_mesa:5']) { + const action: Action = { + type: 'decision', + event: pending.pendingAmbassador!.event, + forces: { 'red_chasm:7': 2 }, + territory: 'south_mesa', + sector: 4, + sandmasterCollect: selected !== undefined, + ...(selected ? { sandmasterPile: selected } : {}), + }; + const done = applyAction(reload(pending), 'a', action); + assert.equal(p(done, 'a').forces['south_mesa:4'], 2); + assert.equal(p(done, 'a').forces['red_chasm:7'] ?? 0, 0); + assert.equal(p(done, 'a').spice, selected ? 21 : 20); + assert.equal(done.spice['south_mesa:4'], selected === 'south_mesa:4' ? 3 : 4); + assert.equal(done.spice['south_mesa:5'], selected === 'south_mesa:5' ? 1 : 2); + assert.equal(p(done, 'a').moved, 0); + assert.equal(p(done, 'a').shipped, false); + assert.equal(p(done, 'a').reserves, 18); + assert.equal(done.pendingAmbassador, null); + } + } +}); + +void test('Ambassador collecting declarations reject missing, malformed, foreign and exhausted pile keys before mutation', () => { + const start = sandmasterFixture(); + start.spice['red_chasm:7'] = 3; + const pending = trigger(enter(start)); + const action: Action = { + type: 'decision', + event: pending.pendingAmbassador!.event, + forces: { 'red_chasm:7': 2 }, + territory: 'south_mesa', + sector: 4, + sandmasterCollect: true, + }; + for (const key of [undefined, '', 'south_mesa:999', 'red_chasm:7', 5, null, ['south_mesa:5']]) + reject(pending, { ...action, sandmasterPile: key } as Action); + reject(pending, { ...action, sandmasterCollect: false, sandmasterPile: 'south_mesa:5' }); + const exhausted = reload(pending); + exhausted.spice['south_mesa:5'] = 0; + reject(exhausted, { ...action, sandmasterPile: 'south_mesa:5' }); + const dead = reload(pending); + p(dead, 'a').leaders[0].dead = true; + reject(dead, { ...action, sandmasterPile: 'south_mesa:5' }); + const captured = reload(pending); + p(captured, 'a').leaders[0].capturedBy = 'in'; + reject(captured, { ...action, sandmasterPile: 'south_mesa:5' }); +}); + +void test('selected Ambassador collection commits before Intrusion and a restored arrival resumes without a second debit', () => { + const start = sandmasterFixture(true, ['beneGesserit']); + army(start, 'beneGesserit', { 'south_mesa:4': 1 }); + let g = trigger(enter(start)); + const event = g.pendingAmbassador!.event; + g = move(g, { + forces: { 'red_chasm:7': 2 }, + territory: 'south_mesa', + sector: 4, + sandmasterCollect: true, + sandmasterPile: 'south_mesa:5', + }); + assert.equal(g.decision?.kind, 'intrusion'); + assert.equal(g.pendingAmbassador?.stage, 'arrival'); + assert.equal(g.pendingAmbassador?.event, event); + assert.equal(p(g, 'a').forces['south_mesa:4'], 2); + assert.equal(p(g, 'a').spice, 21); + assert.equal(g.spice['south_mesa:4'], 4); + assert.equal(g.spice['south_mesa:5'], 1); + g = applyAction(reload(g), 'beneGesserit', { type: 'decision', accept: true }); + g = allow(reload(g)); + assert.equal(p(g, 'a').forces['south_mesa:4'], 2); + assert.equal(p(g, 'a').spice, 21); + assert.equal(g.spice['south_mesa:4'], 4); + assert.equal(g.spice['south_mesa:5'], 1); + assert.equal(g.log.filter(entry => entry.automatic?.name === 'Sandmaster collection').length, 1); + settled(g); +}); + +void test('Ambassador sector relocation within its source territory earns no Sandmaster spice', () => { + const start = sandmasterFixture(); + army(start, 'a', { 'wind_pass:14': 2 }); + start.spice = { 'wind_pass:14': 4, 'wind_pass:15': 2 }; + const pending = trigger(enter(start)); + const action: Action = { + type: 'decision', + event: pending.pendingAmbassador!.event, + forces: { 'wind_pass:14': 2 }, + territory: 'wind_pass', + sector: 15, + }; + reject(pending, { ...action, sandmasterCollect: true, sandmasterPile: 'wind_pass:15' }); + const done = applyAction(reload(pending), 'a', action); + assert.equal(p(done, 'a').forces['wind_pass:15'], 2); + assert.equal(p(done, 'a').spice, 20); + assert.deepEqual(done.spice, start.spice); +}); function move(g: Game, extra: Omit, actor = 'a') { return applyAction(g, actor, { type: 'decision', diff --git a/tests/sandmaster-worm-controls.test.tsx b/tests/sandmaster-worm-controls.test.tsx index 92d80407..93f49b90 100644 --- a/tests/sandmaster-worm-controls.test.tsx +++ b/tests/sandmaster-worm-controls.test.tsx @@ -7,18 +7,14 @@ 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', () => { +void test('worm collection controls are restricted to the eligible owner and disappear when blocked', () => { 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.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() {}, + const render = (player: string) => renderToStaticMarkup(createElement(SandmasterWormChoice, { + quote: sandmasterWormCollection(viewGame(g, player), player, 'red_chasm', 7), + collect: true, onChange() {}, onPileChange() {}, })); - assert.doesNotMatch(blocked, /type="checkbox"/); + assert.match(render('p'), /type="checkbox"/); + assert.equal(render('h'), ''); + g.spice['red_chasm:7'] = 0; + assert.doesNotMatch(render('p'), /<(?:input|select)\b/); }); diff --git a/tests/sandmaster-worm.test.ts b/tests/sandmaster-worm.test.ts index cce1553b..eceb386c 100644 --- a/tests/sandmaster-worm.test.ts +++ b/tests/sandmaster-worm.test.ts @@ -36,7 +36,7 @@ void test('Sandmaster collects once at a worm destination for multiple source se reject(next, action); } }); -void test('optional decline, legacy actions and absent or ambiguous piles keep ordinary worm riding available', () => { +void test('optional decline and legacy actions keep ordinary worm riding available with or without spice', () => { for (const amount of [0, 1, 2]) for (const collect of [false, undefined]) { const g = sandmasterWormGame(); g.spice['red_chasm:7'] = amount; @@ -50,14 +50,52 @@ void test('optional decline, legacy actions and absent or ambiguous piles keep o 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 }); +}); +void test('a selected destination pile supplies only one spice in Basic and Advanced worm rides', () => { + for (const advanced of [false, true]) for (const key of ['pasty_mesa:5', 'pasty_mesa:6']) { + const g = sandmasterWormGame(advanced); + g.spice = { 'wind_pass:14': 7, 'pasty_mesa:5': 2, 'pasty_mesa:6': 3 }; + const action: Action = { ...sandmasterRide(), territory: 'pasty_mesa', sector: 5, sandmasterPile: key }; + 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[key], g.spice[key] - 1); + const other = key === 'pasty_mesa:5' ? 'pasty_mesa:6' : 'pasty_mesa:5'; + assert.equal(next.spice[other], g.spice[other]); + assert.equal(next.spice['wind_pass:14'], 7); + assert.equal(next.players[0].forces['pasty_mesa:5'], 4); + if (advanced) assert.equal(next.players[0].elites!.forces['pasty_mesa:5'], 2); + assert.equal(next.players[0].moved, 1); + assert.equal(next.log.filter(l => l.automatic?.name === 'Sandmaster collection').length, 1); + conserved(next); + } +}); +void test('ambiguous collection needs an exact positive destination pile, but decline still rides', () => { + const g = sandmasterWormGame(); + g.spice = { 'wind_pass:14': 7, 'pasty_mesa:5': 2, 'pasty_mesa:6': 3 }; + const action: Action = { ...sandmasterRide(), territory: 'pasty_mesa', sector: 5 }; + delete action.eliteForces; + reject(g, action); + for (const key of ['wind_pass:14', 'pasty_mesa:9', '', 6]) + reject(g, { ...action, sandmasterPile: key }); + const emptied = structuredClone(g); emptied.spice['pasty_mesa:6'] = 0; + reject(emptied, { ...action, sandmasterPile: 'pasty_mesa:6' }); + const next = applyAction(g, 'p', { ...action, sandmasterCollect: false }); assert.equal(next.players[0].spice, 5); + assert.deepEqual(next.spice, g.spice); + assert.equal(next.players[0].forces['pasty_mesa:5'], 4); + conserved(next); +}); +void test('destination quoting requires current native ride ownership and phase even with a selected pile', () => { + const g = sandmasterWormGame(); + g.spice = { 'pasty_mesa:5': 2, 'pasty_mesa:6': 3 }; + assert.equal(sandmasterWormCollection(g, 'p', 'pasty_mesa', 5, null, 'pasty_mesa:6'), null); + assert.equal(sandmasterWormCollection(g, 'p', 'pasty_mesa', 5, + { kind: 'wormRide', player: 'h', territory: 'wind_pass' }, 'pasty_mesa:6'), null); + const wrongPhase = structuredClone(g); wrongPhase.phase = 5; + assert.equal(sandmasterWormCollection(wrongPhase, 'p', 'pasty_mesa', 5, g.decision, 'pasty_mesa:6'), null); + const nonNative = structuredClone(g); nonNative.players[0].faction = 'guild'; + assert.equal(sandmasterWormCollection(nonNative, 'p', 'pasty_mesa', 5, g.decision, 'pasty_mesa:6'), null); }); void test('failed force selection, storm entry and collecting without a ride cannot debit spice', () => { const g = sandmasterWormGame(true); @@ -77,6 +115,8 @@ void test('Sandmaster requires the owned living native trainer and the supported (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; }, + (g: typeof view) => { g.players[0].leaders.find(l => l.id === g.leaderSkills!.assignments.find(a => a.owner === 'p')!.leader)!.capturedBy = 'h'; }, + (g: typeof view) => { g.players[0].leaders.find(l => l.id === g.leaderSkills!.assignments.find(a => a.owner === 'p')!.leader)!.gholaBy = 'h'; }, ]) { const bad = structuredClone(view); mutate(bad); assert.equal(sandmasterWormCollection(bad, 'p', 'red_chasm', 7), null); @@ -86,23 +126,36 @@ void test('Sandmaster requires the owned living native trainer and the supported (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/); + const quote = sandmasterWormCollection(unsupported, 'p', 'red_chasm', 7)!; + assert.ok(quote.blocked); + assert.deepEqual(quote.piles, []); } }); 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); + for (const multiple of [false, true]) { + const g = sandmasterWormGame(true, true); + const action = sandmasterRide(); + if (multiple) { + g.spice = { 'wind_pass:14': 7, 'pasty_mesa:5': 3, 'pasty_mesa:6': 1 }; + g.players[1].forces = { 'pasty_mesa:5': 1 }; + Object.assign(action, { territory: 'pasty_mesa', sector: 5, sandmasterPile: 'pasty_mesa:6' }); + } else g.spice['red_chasm:7'] = 1; + const key = multiple ? 'pasty_mesa:6' : 'red_chasm:7'; + const pending = applyAction(g, 'p', action); + assert.equal(pending.decision?.kind, 'intrusion'); + assert.equal(pending.players[0].spice, 6); + assert.equal(pending.spice[key], 0); + if (multiple) assert.equal(pending.spice['pasty_mesa:5'], 3); + 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.spice[key], 0); + if (multiple) assert.equal(done.spice['pasty_mesa:5'], 3); + 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) {