From eb63967b5c3b9ea5debb3a52dd54f78f0129453b Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Fri, 9 Oct 2026 05:24:33 +1100 Subject: [PATCH] Connect owned Sandmaster stronghold pile allocation --- components/mobile-stronghold.tsx | 50 +++++++++++++++--- docs/CURRENT_STATUS.md | 16 +++++- docs/IMPLEMENTATION_STATUS.md | 28 ++++++++++ docs/RULE_DECISIONS.md | 2 +- docs/SANDMASTER_MOVEMENT.md | 44 ++++++++++++---- game/bots.ts | 13 +++-- game/engine.ts | 31 +++++++---- game/reference.ts | 2 +- game/sandmaster-movement.ts | 35 +++++++++++++ tests/sandmaster-movement.test.ts | 85 +++++++++++++++++++++++++++++-- 10 files changed, 269 insertions(+), 37 deletions(-) diff --git a/components/mobile-stronghold.tsx b/components/mobile-stronghold.tsx index a4580562..8d38cf65 100644 --- a/components/mobile-stronghold.tsx +++ b/components/mobile-stronghold.tsx @@ -11,6 +11,7 @@ import { territory, } from '@/game/board'; import type { Action, GameView } from '@/game/engine'; +import { quoteSandmasterStronghold } from '@/game/sandmaster-movement'; const label = (key: string) => { const loc = splitLocation(key); return `${territory(loc.territory).name}${loc.sector ? ` · sector ${loc.sector}` : ''}`; @@ -32,6 +33,7 @@ export function MobileStronghold({ const [destination, setDestination] = useState('polar_sink:0'); const [route, setRoute] = useState(pointer ? [pointer] : []); const [collect, setCollect] = useState(true); + const [skillSelections, setSkillSelections] = useState>({}); const max = card ? 2 : 3; const current = route.at(-1) ?? ''; const choices = placement @@ -45,11 +47,20 @@ export function MobileStronghold({ splitLocation(route[0]).sector !== g.storm, ); const me = g.players.find((p) => p.id === g.me)!; - const preview = route.reduce( - (sum, key) => - sum + Math.min(g.spice[key] ?? 0, (me.forces[MOBILE_LOCATION] ?? 0) * 2), - 0, - ); + const sandmaster = placement ? null : quoteSandmasterStronghold(g, me.id, route); + const skillPiles = collect && sandmaster + ? Object.entries(sandmaster.piles).flatMap(([id, keys]) => { + const chosen = skillSelections[id]; + return chosen === false ? [] : [typeof chosen === 'string' && keys.includes(chosen) ? chosen : keys[0]]; + }) : []; + let preview = 0; + const remaining: Record = {}; + for (const key of route) { + const before = remaining[key] ?? Math.max(0, (g.spice[key] ?? 0) - Number(skillPiles.includes(key))); + const amount = Math.min(before, (me.forces[MOBILE_LOCATION] ?? 0) * 2); + remaining[key] = before - amount; + preview += amount; + } const valid = placement || (!blocked && route.length > 1 && @@ -82,7 +93,7 @@ export function MobileStronghold({

{mobileRouteDistance(route)} / {max} territories ·{' '} - {collect ? preview : 0} spice to collect + {collect ? preview + skillPiles.length : 0} spice to collect

)} @@ -121,6 +132,27 @@ export function MobileStronghold({ /> Collect spice along the route + {sandmaster && Object.keys(sandmaster.piles).length > 0 && ( +
+ Sandmaster · one spice per entered territory +

Provisional owner allocation: choose one existing pile or decline each collection. Sandmaster resolves before the faction’s route collection; this is not a publisher allocation ruling.

+ {Object.entries(sandmaster.piles).map(([id, keys]) => ( + + ))} +

{skillPiles.length} Sandmaster spice; {collect ? preview : 0} native faction spice.

+
+ )} {!valid && route.length > 1 && (

Finish in a non-stronghold territory.

)} @@ -134,8 +166,10 @@ export function MobileStronghold({ placement ? { type: 'decision', location: destination } : card - ? { type: 'card', mode: 'special', card, route, collect } - : { type: 'decision', route, collect }, + ? { type: 'card', mode: 'special', card, route, collect, + ...(sandmaster ? { sandmasterPiles: skillPiles } : {}) } + : { type: 'decision', route, collect, + ...(sandmaster ? { sandmasterPiles: skillPiles } : {}) }, ) } > diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index d318ecdb..3f45d669 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 HMS Sandmaster piles — 9 October.** Storm relocation and Advanced +Ixian special Karama now select one bonus debit pile per entered territory, +or decline that bonus while retaining native collection. Existing preparation +and the pending route retain the keys; cancellation transfers nothing. +The preview applies Sandmaster before native collection. Native smoke in +both modes and a labelled special-Karama seam show12 total when debiting +the traversed pile versus13 from another sector. All four legal policies +retain declared keys. At390px, the actual control previews12→13 and commits +Arsunt11/Arsunt12 at0/4 with personal spice10→23. +Types/lint, 15 movement/control and 92 shared special-power cases pass. +See [the HMS policy](SANDMASTER_MOVEMENT.md#owned-native-hms-pile-allocation--9-october-2026); +allocation remains provisional and public/comprehensive gates stay closed. + + **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; @@ -38,7 +52,7 @@ 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. +Types/lint and 91 affected cases pass; the later HMS checkpoint above extends these controls. See [worm](SANDMASTER_WORM.md#connected-behavior) and [physical Ambassador](SANDMASTER_MOVEMENT.md#physical-ambassador-owned-destination-allocation--9-october-2026). diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 0d5e8b0e..a80bd58e 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,33 @@ # Dune implementation status +## 9 October 2026 — owned native HMS Sandmaster pile allocation + +Both native Storm relocation and Advanced special Karama carry optional +explicit `sandmasterPiles`. The common quote supplies one positive key per +entered territory; controls allow a different sector or bonus decline, +without declining native collection. Invalid/undeclared ambiguity rejects +before cost. The original entered-territory eligibility and skill-before- +faction order come from the 7 October ruling; owner allocation is provisional. + +Selected keys persist through the ordinary Karama window, special intent +and action reconstruction. Older queued moves retain their original single- +pile behavior. Native geography, ordinary transport allowances and +faction collection caps stay unchanged. The UI preview simulates skill +debits before faction collection and depletion on repeated route sectors. + +Basic/Advanced native smoke: six passengers, traversed Arsunt11=12 and +Arsunt12=5. Selecting11 earns12; selecting12 earns13 and leaves0/4. +Cancellation leaves pointer/piles/balance unchanged. JSON continues the +selected pending route. All four policies emit legal selected routes. +A labelled phase-five special-Karama seam moves/collects once, spends the +physical card once and preserves ordinary shipment/movement. +At390px, production controls show12→13, exercise bonus decline/reselection +and commit the same0/4 piles with spice10→23 after native allowance. + +`check:quick`, 15 movement/control and 92 shared mobile/special/Kull cases +pass. No full suite, new recovery campaign or deployment was run. +Temporary scripts and the dedicated in-memory QA service were removed. + ## 9 October 2026 — owned worm and physical Ambassador destination piles The shared destination quote exposes all legal positive piles and an exact diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index bde2f9a5..e178ceed 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 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) | +| Multi-pile Sandmaster movement | Ground movement and native HMS relocation choose one existing positive debit sector per entered territory; worm and physical Fremen Ambassador relocation choose one at their destination, or decline collection. HMS selection precedes unchanged native sector collection and survives cancellation/special preparation. Duplicate territory and undeclared ambiguous collection reject. Provisional owner allocation, not a publisher ruling. | [Ground](SANDMASTER_MOVEMENT.md#owned-ground-route-pile-selection--9-october-2026), [HMS](SANDMASTER_MOVEMENT.md#owned-native-hms-pile-allocation--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 fa76a4f4..cd9b4a52 100644 --- a/docs/SANDMASTER_MOVEMENT.md +++ b/docs/SANDMASTER_MOVEMENT.md @@ -30,11 +30,36 @@ becomes3, and three forces arrive in Cielago East. 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. +in [its own contract](SANDMASTER_WORM.md). Native HMS relocation now has +the owned-pile control described below. No public start or broader source +adjudication is opened by this ground-route change. +## Owned native HMS pile allocation — 9 October 2026 + +Storm relocation and Advanced Ixian special Karama now offer one Sandmaster +debit pile per entered territory, or an independent decline of that bonus. +The global collection opt-out still declines all collection. The shared +quote requires a living native Ixian trainer and actual interior passengers. +Explicit `sandmasterPiles` declarations preserve the exact chosen keys +through native cancellation and special-Karama preparation/reconstruction. +Ambiguous undeclared or duplicate-territory selections reject before cost. + +Sandmaster debits first, then the unchanged native collection uses the +traversed sectors and two-spice-per-passenger cap. The route preview follows +the same order, including repeated sector visits. With six passengers, +Arsunt11 holding12 and Arsunt12 holding5, choosing11 earns12 total; +choosing12 earns13, leaves Arsunt12 at4 and drains Arsunt11. Native +cancellation moves neither pointer nor spice. Older queued moves without +the new selection retain their original unambiguous-pile behavior. + +Owner sector allocation is provisional, not publisher adjudication; the +7 October user eligibility/order ruling is unchanged. Basic/Advanced +native smoke, selected-key JSON continuation, cancellation, all four legal +policies and a labelled Advanced special-Karama seam pass. At390px, +the real control changes the preview12→13, declines/reselects the bonus +and completes the native route with personal spice10→23. + ## Physical Ambassador owned-destination allocation — 9 October 2026 Physical Fremen Ambassador relocation now offers every existing positive @@ -168,8 +193,8 @@ 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 HMS collection and wider -interaction/strategy acceptance remain. The lower battle band is in +concealed-marker collection and wider interaction/strategy acceptance remain. +The lower battle band is in [the battle-effects contract](LEADER_BATTLE_EFFECTS.md). ## 21 September: native HMS relocation eligibility @@ -192,8 +217,7 @@ The checked card, designer walkthrough and available official rules did not clarify whether passengers staying in the interior count as entering outside territories. **Resolved 7 October 2026 by user ruling:** interior passengers **do count as entering** the outside territories the stronghold points into, so -the Sandmaster skill collects one spice per entered territory with an -unambiguous pile during a native relocation, before the faction's own -traversed-sector collection. Skill-before-faction precedence settles the -ordering. Ordinary Sandmaster entry/exit and native relocation are unchanged; -full combined compliance remains unverified. +the Sandmaster skill collects one spice per entered territory before the +faction's own traversed-sector collection. The 9 October owner-allocation +prototype above extends the earlier unambiguous-pile path; it does not +change the user ruling. Full combined compliance remains unverified. diff --git a/game/bots.ts b/game/bots.ts index 6074fb0c..bc5fcbb6 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -99,7 +99,7 @@ import { guildTransportCost, } from './bot-mobility'; import { planetologistMovementModeSupported, planetologistLeader } from './planetologist-movement'; -import { sandmasterDefaultChoice, sandmasterDestinationCollection } from './sandmaster-movement'; +import { quoteSandmasterStronghold, sandmasterDefaultChoice, sandmasterDestinationCollection } from './sandmaster-movement'; import { sandmasterWormCollection } from './sandmaster-worm'; import { richeseCardDefinition } from './richese-cards'; import { presenceAt } from './force-presence'; @@ -1452,15 +1452,18 @@ function policyActions(g: GameView): Action[] { ), })) .sort((a, b) => b.value - a.value)[0]; - if (route && route.value >= [4, 5, 6, 6][level]) + if (route && route.value >= [4, 5, 6, 6][level]) { + const sandmaster = quoteSandmasterStronghold(g, me.id, route.route); return [ { type: 'card', mode: 'special', card: specialCard.id, route: route.route, + ...(sandmaster ? { sandmasterPiles: sandmaster.collect } : {}), }, ]; + } } if ( specialCard && @@ -2209,7 +2212,11 @@ function policyActions(g: GameView): Action[] { return [ ...routes .filter((r) => r.score > 0) - .map((r) => ({ type: 'decision', route: r.route })), + .map(r => { + const sandmaster = quoteSandmasterStronghold(g, me.id, r.route); + return { type: 'decision', route: r.route, + ...(sandmaster ? { sandmasterPiles: sandmaster.collect } : {}) }; + }), { type: 'decision', decline: true }, ]; } diff --git a/game/engine.ts b/game/engine.ts index b4b927d6..87a5b02a 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -21,7 +21,7 @@ import { planetologistMovementModeSupported, planetologistLeader, planetologistR import { quoteSmugglerNoField, smugglerNoFieldModeSupported, type SmugglerNoFieldCompanion } from './smuggler-no-field'; import { quoteSmugglerShipment, type SmugglerShipment } from './smuggler-shipment'; import { createSmugglerBattle, settleSmugglerBattle, smugglerBattleModeSupported, smugglerBattlePlanBlock, smugglerBattlePile, smugglerBattleSignature, smugglerBattleAllocationRequired, chooseSmugglerBattleAllocation, type SmugglerBattleReceipt, type SmugglerBattleCollectionOffer } from './smuggler-battle'; -import { quoteSandmasterMovement, validateSandmasterMovement, sandmasterCollectionPiles, sandmasterLeader, sandmasterRouteDistance, type SandmasterMovement, type SandmasterOrder } from './sandmaster-movement'; +import { quoteSandmasterMovement, validateSandmasterMovement, sandmasterCollectionPiles, sandmasterLeader, sandmasterRouteDistance, validateSandmasterStronghold, type SandmasterMovement, type SandmasterOrder } from './sandmaster-movement'; import { sandmasterWormCollection } from './sandmaster-worm'; import { spiceBankerModeSupported, validateSpiceBankerSpend } from './spice-banker'; import { BankerIncomeError, createBankerIncomeState, validateBankerIncomeState, quoteBankerIncome, commitBankerIncome, quoteBankerIncomeCollection, commitBankerIncomeCollection, projectBankerIncome, type BankerIncomeState, type BankerIncomeAuthority, type BankerIncomeContext } from './spice-banker-income'; @@ -1829,6 +1829,7 @@ export type Game = { player: string; route: string[]; collect: boolean; + sandmasterPiles?: string[]; } | null; revivalRules?: RevivalRules; revivalRequests?: Record< @@ -25771,23 +25772,24 @@ function validateMobileMove(g: Game, p: Player, input: unknown, max: number) { } function relocateMobileStronghold( g: Game, - move: { player: string; route: string[]; collect: boolean }, + move: { player: string; route: string[]; collect: boolean; sandmasterPiles?: string[] }, ) { const p = getPlayer(g, move.player); const blocked = homeworldRule(() => homeworldMobileStrongholdMovementBlock(g, p.id)); requireRule(!blocked, blocked ?? 'The mobile stronghold cannot move.'); + const skillPiles = move.sandmasterPiles !== undefined + ? nexusRule(() => validateSandmasterStronghold(g, p.id, move.route, move.sandmasterPiles, move.collect)) + : sandmasterLeader(g, p.id) + ? Object.values(sandmasterCollectionPiles(g, { interior: move.route })).filter(piles => piles.length === 1).map(piles => piles[0]) + : []; let collected = 0; if (move.collect) { // User ruling 7 October 2026: passengers remaining inside a relocated HMS // interior count as entering the outside territories the stronghold points // into, so the Sandmaster skill collects there before the faction's own // traversed-sector collection. - if (sandmasterLeader(g, move.player)) - for (const piles of Object.values(sandmasterCollectionPiles(g, { interior: move.route }))) { - // HMS relocation keeps its existing single-pile scope until its - // separate route controls can select the Sandmaster debit sector. - if (piles.length !== 1) continue; - const pile = piles[0]; + // Unselected older queued moves retain their original unambiguous-pile path. + for (const pile of skillPiles) { g.spice[pile]--; if (!g.spice[pile]) delete g.spice[pile]; p.spice++; @@ -26760,7 +26762,7 @@ export type SpecialKaramaIntent = { } & ( | { kind: 'choam'; cards: string[] } | { kind: 'richese'; acquire: string } - | { kind: 'ixians'; route: string[]; collect: boolean } + | { kind: 'ixians'; route: string[]; collect: boolean; sandmasterPiles?: string[] } | { kind: 'tleilaxu'; target: string; revival: PendingRevival } | { kind: 'fremen'; territory: string } | { @@ -26907,11 +26909,14 @@ export function prepareSpecialKaramaIntent( 'Move the stronghold with special Karama during your own Shipment and Movement turn.', ); const route = validateMobileMove(g, p, action.route, 2); + const sandmasterPiles = nexusRule(() => validateSandmasterStronghold( + g, p.id, route, action.sandmasterPiles, action.collect !== false)); return { ...base, kind: 'ixians', route: [...route], collect: action.collect !== false, + ...(action.sandmasterPiles !== undefined ? { sandmasterPiles } : {}), }; } else if (p.faction === 'tleilaxu') { requireRule( @@ -27082,7 +27087,8 @@ function specialKaramaAction(intent: SpecialKaramaIntent): Action { case 'choam': return { ...action, cards: [...intent.cards] }; case 'ixians': - return { ...action, route: [...intent.route], collect: intent.collect }; + return { ...action, route: [...intent.route], collect: intent.collect, + ...(intent.sandmasterPiles !== undefined ? { sandmasterPiles: [...intent.sandmasterPiles] } : {}) }; case 'tleilaxu': case 'atreides': case 'guild': @@ -27241,6 +27247,7 @@ export function executeSpecialKaramaIntent( player: p.id, route, collect: intent.collect, + ...(intent.sandmasterPiles !== undefined ? { sandmasterPiles: intent.sandmasterPiles } : {}), }); } else if (intent.kind === 'tleilaxu') { const target = intent.target; @@ -29089,10 +29096,13 @@ function applyActionInner( } else if (action.decline === true) beginStormTurn(g); else { const route = validateMobileMove(g, p, action.route, 3); + const sandmasterPiles = nexusRule(() => validateSandmasterStronghold( + g, p.id, route, action.sandmasterPiles, action.collect !== false)); g.pendingMobileMove = { player: p.id, route, collect: action.collect !== false, + ...(action.sandmasterPiles !== undefined ? { sandmasterPiles } : {}), }; g.response = { kind: 'mobileStronghold', @@ -32366,6 +32376,7 @@ export function viewGame(state: Game, id: string) { ? { route: g.pendingMobileMove.route, collect: g.pendingMobileMove.collect, + sandmasterPiles: g.pendingMobileMove.sandmasterPiles, } : null, techTokens: g.techTokens ?? null, diff --git a/game/reference.ts b/game/reference.ts index 2aa1f317..916aae27 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 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 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; 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 opportunities. Native worm rides and physical Fremen Ambassador relocation offer one selected destination pile before arrival reactions, without an invented route or normal movement use. Native Ixian HMS passengers choose one pile in each entered territory before faction collection, including supplied Advanced special Karama; its route preview reflects both transfers. These allocations are provisional. Other special relocation remains 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 c941674c..dc68ff60 100644 --- a/game/sandmaster-movement.ts +++ b/game/sandmaster-movement.ts @@ -178,6 +178,41 @@ export function sandmasterCollectionPiles( } return choices; } + +export function quoteSandmasterStronghold(game: Game | GameView, player: string, route: string[]) { + const seat = game.players.find(p => p.id === player); + const leader = sandmasterLeader(game, player); + if (!leader || !sandmasterModeSupported(game) || seat?.faction !== 'ixians' || + !(seat.forces[MOBILE_LOCATION] > 0)) return null; + const piles = sandmasterCollectionPiles(game, { interior: route }); + return { leader, piles, collect: Object.values(piles).map(keys => keys[0]) }; +} + +/** Native collection remains separate; this selects only the skill's one spice per territory. */ +export function validateSandmasterStronghold( + game: Game | GameView, player: string, route: string[], selection: unknown, enabled = true, +): string[] { + if (!enabled) { + requireMove(selection === undefined || (Array.isArray(selection) && selection.length === 0), + 'Declining stronghold collection cannot take Sandmaster spice.'); + return []; + } + const quote = quoteSandmasterStronghold(game, player, route); + if (selection === undefined) { + requireMove(!quote || Object.values(quote.piles).every(keys => keys.length === 1), + 'Choose the Sandmaster debit pile for each entered territory, or decline its collection.'); + return quote?.collect ?? []; + } + requireMove(Array.isArray(selection) && selection.every(key => typeof key === 'string'), + 'Choose Sandmaster pile keys for this stronghold route.'); + requireMove(selection.length === 0 || !!quote, + 'Stronghold Sandmaster collection needs its living native trainer and passengers.'); + requireMove(selection.every(key => quote?.piles[splitLocation(key).territory]?.includes(key) && + Number.isSafeInteger(game.spice[key]) && game.spice[key] > 0) && + new Set(selection.map(key => splitLocation(key).territory)).size === selection.length, + 'Collect from at most one positive pile per entered territory.'); + return [...selection]; +} export function quoteSandmasterMovement( g: Game | GameView, player: string, diff --git a/tests/sandmaster-movement.test.ts b/tests/sandmaster-movement.test.ts index b513a82c..62552997 100644 --- a/tests/sandmaster-movement.test.ts +++ b/tests/sandmaster-movement.test.ts @@ -25,10 +25,9 @@ const restored = (g: Game): Game => JSON.parse(JSON.stringify(g)); function unchanged( g: Game, action: Action, - error = /Sandmaster|route|collection|source|spice/i, ) { const before = structuredClone(g); - assert.throws(() => applyAction(g, 'p', action), error); + assert.throws(() => applyAction(g, 'p', action)); assert.deepEqual(g, before); } function conserved(g: Game, before: Game) { @@ -42,6 +41,86 @@ function conserved(g: Game, before: Game) { game.players.reduce((a, p) => a + p.spice, 0); assert.equal(sum(g), sum(before)); } + +function strongholdPileFixture(advanced = false) { + let game = smugglerShipmentGame('ixians', advanced, 'sandmaster', undefined, ['ix']); + game.players[0].forces = { [INSIDE]: 6 }; + game.players[0].reserves = 14; + if (game.players[0].elites) game.players[0].elites.forces = { [INSIDE]: 3 }; + game.mobileStronghold = { location: 'polar_sink:0' }; + game.spice = {}; + game.phase = 8; + game.active = null; + for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' }); + for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' }); + assert.equal(game.decision?.kind, 'mobileStronghold'); + const route = mobileRoutes(game, 1).find(keys => mobileRouteDistance(keys) === 1 && + territory(splitLocation(keys.at(-1)!).territory).sectors.some( + sector => sector !== splitLocation(keys.at(-1)!).sector && sector !== game.storm))!; + assert.ok(route); + const landing = route.at(-1)!; + const at = splitLocation(landing); + const other = `${at.territory}:${territory(at.territory).sectors.find( + sector => sector !== at.sector && sector !== game.storm)!}`; + game.spice = { [landing]: 12, [other]: 5 }; + const karama = game.deck.find(card => card.effect === 'karama')!; + assert.ok(karama); + game.deck = game.deck.filter(card => card.id !== karama.id); + game.players[1].hand.push(karama); + return { game, route, landing, other, karama }; +} + +function allowStrongholdMove(state: Game): Game { + let game = state; + while (game.response) { + const owner = game.players.find(player => !game.response!.passed.includes(player.id))!.id; + game = applyAction(game, owner, { type: 'passResponse' }); + } + return game; +} + +void test('stronghold Sandmaster sector selection precedes native collection and cancellation transfers nothing', () => { + for (const advanced of [false, true]) { + const { game, route, landing, other, karama } = strongholdPileFixture(advanced); + unchanged(game, { type: 'decision', route }); + unchanged(game, { type: 'decision', route, sandmasterPiles: [landing, other] }); + for (const selected of [landing, other]) { + const pending = applyAction(game, 'p', { type: 'decision', route, sandmasterPiles: [selected] }); + assert.equal(pending.players[0].spice, game.players[0].spice); + const done = allowStrongholdMove(restored(pending)); + assert.equal(done.mobileStronghold!.location, landing); + assert.equal(done.spice[landing] ?? 0, 0); + assert.equal(done.spice[other], selected === other ? 4 : 5); + assert.equal(done.players[0].spice - game.players[0].spice, selected === other ? 13 : 12); + } + const pending = applyAction(game, 'p', { type: 'decision', route, sandmasterPiles: [other] }); + const canceled = applyAction(pending, game.players[1].id, { type: 'card', card: karama.id, mode: 'cancel' }); + assert.deepEqual(canceled.spice, game.spice); + assert.equal(canceled.mobileStronghold!.location, 'polar_sink:0'); + assert.equal(canceled.players[0].spice, game.players[0].spice); + } +}); + +void test('Ixian special Karama retains selected Sandmaster pile without consuming ordinary transport', () => { + const { game, route, landing, other, karama } = strongholdPileFixture(true); + // Conserved held-card transfer and labelled phase-five budget seam. + game.phase = 5; + game.active = 'p'; + game.decision = null; + game.response = null; + game.players[1].hand = game.players[1].hand.filter(card => card.id !== karama.id); + game.players[0].hand.push(karama); + const done = applyAction(game, 'p', + { type: 'card', mode: 'special', card: karama.id, route, sandmasterPiles: [other] }); + assert.equal(done.mobileStronghold!.location, landing); + assert.equal(done.spice[landing] ?? 0, 0); + assert.equal(done.spice[other], 4); + assert.equal(done.players[0].spice - game.players[0].spice, 13); + assert.equal(done.players[0].moved, game.players[0].moved); + assert.equal(done.players[0].shipped, game.players[0].shipped); + assert.equal(done.players[0].specialKaramaUsed, true); + assert.equal(done.discard.filter(card => card.id === karama.id).length, 1); +}); void test('Sandmaster collects once in every selected traversed territory and preserves JSON continuation', () => { for (const advanced of [false, true]) { const g = sandmasterMovementGame('emperor', advanced), @@ -137,7 +216,7 @@ void test('Sandmaster rejects disconnected, overlong, storm and duplicate collec unchanged(g, { ...action, sandmaster: null }); const blocked = restored(g); blocked.storm = splitLocation(route.at(-1)!).sector; - unchanged(blocked, action, /storm|blocked|Sandmaster/i); + unchanged(blocked, action); const tooFar = restored(g); delete tooFar.players[0].forces['arrakeen:10']; tooFar.players[0].reserves++;