From ac1f32c8593babf088859f03edc6eb3caad4709b Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Thu, 29 Oct 2026 07:33:59 +1100 Subject: [PATCH] Respect storm sector for rides into mobile stronghold --- components/game-table.tsx | 6 +- docs/CURRENT_STATUS.md | 11 ++ docs/RULE_DECISIONS.md | 12 ++ game/bots.ts | 3 +- game/engine.ts | 11 +- game/nexus-fremen-cunning.ts | 3 + game/reference.ts | 1 + game/worm-ride-destination.ts | 21 +++ tests/worm-ride-destination.test.ts | 29 ++++ .../worm-ride-mobile-storm-controls.test.tsx | 97 +++++++++++++ tests/worm-ride-mobile-storm-runtime.test.ts | 133 ++++++++++++++++++ 11 files changed, 324 insertions(+), 3 deletions(-) create mode 100644 game/worm-ride-destination.ts create mode 100644 tests/worm-ride-destination.test.ts create mode 100644 tests/worm-ride-mobile-storm-controls.test.tsx create mode 100644 tests/worm-ride-mobile-storm-runtime.test.ts diff --git a/components/game-table.tsx b/components/game-table.tsx index 756569f5..64c6f74c 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -140,6 +140,7 @@ import { SandmasterMovement } from './sandmaster-movement'; import { SandmasterWormChoice } from './sandmaster-worm'; import { sandmasterWormCollection, sandmasterCunningCollection } from '@/game/sandmaster-worm'; import { fremenCunningPublicDestinationBlock } from '@/game/nexus-fremen-cunning'; +import { wormRideDestinationBlock } from '@/game/worm-ride-destination'; import { ResidualPoison, BattleLeaderOpportunity } from './residual-poison'; import { PortableSnooper } from './portable-snooper'; import { @@ -634,6 +635,8 @@ export function GameTable({ ? { ...baseSandmasterRide, key: selectedRidePile.key, before: selectedRidePile.before } : baseSandmasterRide; const cunningDestinationBlocked = g.decision?.kind === 'nexusFremenCunningRide' && source ? fremenCunningPublicDestinationBlock(g, me.id, source, { territory: selected, sector }) : null; + const wormDestinationBlocked = g.decision?.kind === 'wormRide' + ? wormRideDestinationBlock(g, selected, sector) : null; const [exchangeCards, setExchangeCards] = useState>( {}, ); @@ -4181,6 +4184,7 @@ export function GameTable({ ))} {destination} + {wormDestinationBlocked &&

{wormDestinationBlocked}

} {me.elites && Object.entries(rideForces) .filter( @@ -4218,7 +4222,7 @@ export function GameTable({ (g.decision as { territory: string }).territory, ), ), - })} + }, !!wormDestinationBlocked)} {actionButton('Stay in this territory', { type: 'decision', accept: false, diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index bc1d6ad1..7cb90ac1 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,17 @@ open mode gates. ## Current checkpoint and work +**Storm and worm rides into the mobile stronghold — 29 October. Prototyped / +Partial.** Ordinary, Cunning and Great Maker worm rides now check the mobile +stronghold's actual pointing sector rather than treating its synthetic interior +sector as storm-free. Human controls show the reason and bots omit the blocked +choice. Polar Sink and shelter for existing occupants remain unchanged. The +11-file related batch passes 45 cases, along with type checking, lint and the +production build. Cunning and Great Maker use actual +encounters with staged storm/marker positions, while the ordinary ride uses a +conserved staged decision. The table check is server-rendered, not hydrated or +deployed acceptance. [Source and interpretation](RULE_DECISIONS.md#worm-rides-into-the-mobile-stronghold--29-october-2026). + **Basic Richese No-Field interruptions — 29 October. Prototyped / Partial.** In supported Basic native Ix/CHOAM games the owner can reveal a deployed No-Field during an Ix opening and selected economic decisions without replacing diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index d1de9455..d3b4e1dc 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,17 @@ # Rule decision index +## Worm rides into the mobile stronghold — 29 October 2026 + +The [publisher FAQ, p. 5](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf) +allows Fremen to ride directly into the Hidden Mobile Stronghold when the +destination sector is clear of storm. The game represents its interior as +sector zero; this implementation maps that check to the stronghold's actual +pointing sector. That mapping is an online interpretation of the FAQ, while +forces already inside retain the printed storm shelter. Ordinary, Cunning and +Great Maker rides share this destination check; Polar Sink remains clear. +[Shared quote](../game/worm-ride-destination.ts), +[runtime cases](../tests/worm-ride-mobile-storm-runtime.test.ts). + ## Basic leaderless battle announcement — 29 October 2026 The [publisher base rulebook, printed p. 10](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf) diff --git a/game/bots.ts b/game/bots.ts index bed554fb..2ebf92d4 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -3,6 +3,7 @@ import { isAlliedShipmentVisit } from './allied-separation'; import { discoveryBotActions } from './discovery-options'; import { normalAuctionHandBlock } from './normal-auction'; import { fremenRevivalGrantBlock } from './fremen-revival-grant'; +import { wormRideDestinationBlock } from './worm-ride-destination'; import { atreidesFullPlanAction } from './atreides-full-plan'; import { liveMovementPromises } from './movement-promises'; import { ecazOccupyDialChoices, ecazOccupyOwnProfile, ecazOccupyPolicyActions } from './ecaz-occupy-options'; @@ -3055,7 +3056,7 @@ function policyActions(g: GameView): Action[] { .filter( (to) => to.t !== d.territory && botEntryAllowed(g, me, to.t, to.s, 'move') && - !botWormRideArrivalBlock(g, to.t, to.s), + !wormRideDestinationBlock(g, to.t, to.s) && !botWormRideArrivalBlock(g, to.t, to.s), ) .slice(0, 16) .map( diff --git a/game/engine.ts b/game/engine.ts index b8ad7c32..7f99afe9 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -665,6 +665,7 @@ import { choamGholaEvent, choamGholaSkillOfferEvent, choamGholaSkillSignature, c import { quoteAdvisorPreparationWindow, quoteAdvisorPreparation, validateAdvisorPreparation, projectAdvisorPreparationWindow, AdvisorPreparationError, type AdvisorPreparationWindow, type AdvisorPreparationSource } from './advisor-preparation-window'; import { quoteFremenRevivalGrant, validateFremenRevivalGrant, projectFremenRevivalGrant, projectFremenGroupRevivalGrant, FremenRevivalGrantError, type FremenRevivalGrantSource } from './fremen-revival-grant'; import { richeseRevealInterruptSupported } from './richese-reveal-interruption'; +import { wormRideDestinationBlock } from './worm-ride-destination'; import { splitOrdinaryPayments, type OrdinaryPayment } from './ordinary-payment-plan'; import { charityAmount, @@ -14655,7 +14656,11 @@ function greatMakerRideOptions(g: Game, owner: string) { if (frame?.stage !== 'ride' || p.faction !== 'fremen' || p.fremenNexusWormBlockedTurn === g.turn) return null; const destinations = gameTerritories(g).flatMap(t => t.sectors.flatMap(sector => { - try { allowedEntry(g,p,t.id,sector); return [{territory:t.id,name:t.name,sector}]; } + try { + allowedEntry(g,p,t.id,sector); + if (wormRideDestinationBlock(g,t.id,sector)) return []; + return [{territory:t.id,name:t.name,sector}]; + } catch (error) { if (error instanceof RuleError) return []; throw error; } })); return {max:p.reserves,eliteMax:p.elites?.reserves ?? 0,destinations}; @@ -14758,6 +14763,8 @@ function decideGreatMakerRide(g: Game, p: Player, action: Action) { const amount = integer(action.amount,1,p.reserves,'Riding forces'); const elite = eliteChoice(amount,p.reserves,p.elites?.reserves ?? 0,action.elite); allowedEntry(g,p,to,sector); + const stormBlock = wormRideDestinationBlock(g,to,sector); + requireRule(!stormBlock, stormBlock ?? 'That worm-ride destination is in storm.'); withdrawNativeReserves(g,p,amount,elite); place(p,to,sector,amount,elite); frame.arrival = {territory:to,sector,amount,elite}; @@ -45334,6 +45341,8 @@ function applyActionInner( 'Choose another destination territory.', ); allowedEntry(g, p, to, sector); + const stormBlock = wormRideDestinationBlock(g, to, sector); + requireRule(!stormBlock, stormBlock ?? 'That worm-ride destination is in storm.'); const sandmaster = action.sandmasterCollect ? sandmasterWormCollection(g, p.id, to, sector, decision, action.sandmasterPile as string | undefined) : null; if (action.sandmasterCollect) { diff --git a/game/nexus-fremen-cunning.ts b/game/nexus-fremen-cunning.ts index 378ede38..01961715 100644 --- a/game/nexus-fremen-cunning.ts +++ b/game/nexus-fremen-cunning.ts @@ -3,6 +3,7 @@ import type { Game, GameView, Player } from './engine'; import { presenceAt } from './force-presence'; import { nexusCardMode, validateNexusCards } from './nexus-cards'; import { territoryEntryBlock } from './occupancy'; +import { wormRideDestinationBlock } from './worm-ride-destination'; /** The engine allocates a distinct event for each accepted worm appearance, even * when two worms visit the same territory. Capture before any devouring. */ @@ -137,6 +138,8 @@ export function fremenCunningPublicDestinationBlock( return 'Ride into the mobile stronghold only from the territory it points at.'; } if (sector !== 0 && sector === g.storm) return 'That sector is in storm.'; + const stormBlock = wormRideDestinationBlock(g, to, sector); + if (stormBlock) return stormBlock; return territoryEntryBlock(g.players, owner, to); } diff --git a/game/reference.ts b/game/reference.ts index ad74fd91..1641f7f5 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -2135,6 +2135,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Before later storm dials or card reveals, occupied Ixians may keep the current position or declare a route of up to three territories. Use the route selector to name each sector. The destination must be a non-stronghold territory. The route cannot begin, end or pass through the current storm sector. Occupants stay inside throughout relocation.', 'Choose the spice collected in each visited sector, including the starting sector. This preview provisionally permits any whole amount up to the available pile and two per eligible force inside, separately in each sector. Foreign fighters count, a concealed No-Field counts as one, and advisors do not count. Older moves without selected amounts retain their Ixian-only automatic limit. A public Karama response resolves before relocation or collection. The printed rule does not detail partial allocations or all passenger interactions.', 'Forces inside are separate from forces outside. Enter and leave through the pointing sector, counting the stronghold as one more territory of movement. A storm in that pointing sector blocks ordinary entry or exit. Direct shipping inside is restricted to Ixians; advanced Bene Gesserit may accompany their shipment.', + 'Fremen may ride a worm directly into the mobile stronghold when its pointing sector is clear of the storm. Its sheltered occupants remain protected while that sector is in storm.', 'Worms and storms do not kill the occupants. Battles wait while the pointing sector is in storm; otherwise normal battles and the two-faction capacity apply inside. The sole controller counts it toward victory, even when that controller is not Ixian. Capturing it does not grant another faction permission to move it.', 'In advanced play, Ixians may spend special Karama once during their own Shipment and Movement turn to relocate up to two territories while their forces occupy it. This preserves their ordinary shipment and troop movement. Setup and bidding technology now have decision controls; expansion starts and remaining interaction audits are unfinished.', ], diff --git a/game/worm-ride-destination.ts b/game/worm-ride-destination.ts new file mode 100644 index 00000000..62b132e3 --- /dev/null +++ b/game/worm-ride-destination.ts @@ -0,0 +1,21 @@ +import { MOBILE_STRONGHOLD, splitLocation, validLocation } from './board'; + +/** The mobile stronghold's interior is encoded as sector 0, but a worm ride + * enters through its actual pointed sector. Ordinary sectors and the Polar + * Sink keep their existing independent entry checks. */ +export function wormRideDestinationBlock( + g: {storm: number; mobileStronghold?: {location: string | null} | null}, + territory: string, + _sector: number, +): string | null { + if (territory !== MOBILE_STRONGHOLD || !g.mobileStronghold?.location || + !Number.isSafeInteger(g.storm) || g.storm < 1 || g.storm > 18) return null; + const pointer = g.mobileStronghold.location; + const parts = pointer.split(':'); + if (parts.length !== 2) return null; + const at = splitLocation(pointer); + if (!validLocation(at.territory, at.sector) || at.sector < 1) return null; + return at.sector === g.storm + ? 'The Hidden Mobile Stronghold points into the storm; a worm ride cannot enter it.' + : null; +} diff --git a/tests/worm-ride-destination.test.ts b/tests/worm-ride-destination.test.ts new file mode 100644 index 00000000..52fca441 --- /dev/null +++ b/tests/worm-ride-destination.test.ts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { MOBILE_STRONGHOLD } from '../game/board'; +import { wormRideDestinationBlock as block } from '../game/worm-ride-destination'; + +void test('the storm at the real pointer blocks riding into the sector-zero mobile interior', () => { + for (const location of ['arsunt:11', 'arrakeen:10', 'imperial_basin:9']) { + const storm = Number(location.split(':')[1]); + const game = {storm, mobileStronghold:{location}}, before = structuredClone(game); + assert.match(block(game, MOBILE_STRONGHOLD, 0)!, /points into the storm/); + assert.deepEqual(game, before); + assert.equal(block({...game, storm:storm === 18 ? 1 : storm + 1}, MOBILE_STRONGHOLD, 0), null); + } +}); + +void test('ordinary territories and the Polar Sink keep their existing entry checks', () => { + const game = {storm:11, mobileStronghold:{location:'arsunt:11'}}; + assert.equal(block(game, 'arsunt', 11), null); + assert.equal(block(game, 'polar_sink', 0), null); + assert.equal(block({storm:0, mobileStronghold:{location:'polar_sink:0'}}, MOBILE_STRONGHOLD, 0), null); +}); + +void test('an absent or malformed marker is left to the existing placement validation', () => { + assert.equal(block({storm:11}, MOBILE_STRONGHOLD, 0), null); + assert.equal(block({storm:11, mobileStronghold:null}, MOBILE_STRONGHOLD, 0), null); + assert.equal(block({storm:11, mobileStronghold:{location:null}}, MOBILE_STRONGHOLD, 0), null); + for (const location of ['missing', 'arsunt:11:extra', 'unknown:11', 'arsunt:not-a-sector']) + assert.equal(block({storm:11, mobileStronghold:{location}}, MOBILE_STRONGHOLD, 0), null); +}); diff --git a/tests/worm-ride-mobile-storm-controls.test.tsx b/tests/worm-ride-mobile-storm-controls.test.tsx new file mode 100644 index 00000000..d2316dc2 --- /dev/null +++ b/tests/worm-ride-mobile-storm-controls.test.tsx @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { registerHooks } from 'node:module'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import test from 'node:test'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { createGame, newPlayer, viewGame, type Game } from '../game/engine'; +import { MOBILE_STRONGHOLD, location, territory } from '../game/board'; + +// SSR cannot operate a select. Seed exactly the state normally set by the +// destination/sector and force controls while still rendering real GameTable. +// Derive hook indices from declaration names so unrelated inserted hooks do not +// change the selected state. This does not claim a hydrated browser interaction. +const file = resolve('components/game-table.tsx'); +const sourceText = readFileSync(file, 'utf8'); +const names = [...sourceText.matchAll(/const\s+\[\s*([\w$]+)\s*,[^\]]+\]\s*=\s*useState/g)].map(match => match[1]); +const indices = Object.fromEntries(['selectedId', 'sector', 'rideForces'].map(name => { + const index = names.indexOf(name); assert.ok(index >= 0, `Missing ${name} table state`); return [name, index]; +})); +const reactUrl = pathToFileURL(resolve('node_modules/react/index.js')).href; +const mockReact = `import {useState as original} from ${JSON.stringify(reactUrl)}; +export function useState(initial) { + const config = globalThis.__duneWormRideHarness; + const index = config.next++; + if (index === config.indices.selectedId) initial = config.destination; + if (index === config.indices.sector) initial = 0; + if (index === config.indices.rideForces) initial = {[config.source]: 4}; + return original(initial); +}`; +const aliases = registerHooks({ + resolve(specifier, context, next) { + if (specifier === 'react' && context.parentURL?.endsWith('/components/game-table.tsx')) + return {url:`data:text/javascript,${encodeURIComponent(mockReact)}`, shortCircuit:true}; + return next(specifier === 'next/image' ? 'vinext/shims/image' : + specifier === 'next/link' ? 'vinext/shims/link' : specifier, context); + }, + load(url, context, next) { + if (!url.endsWith('.module.css')) return next(url, context); + const css = readFileSync(new URL(url), 'utf8'); + const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]])); + return {format:'module', source:`export default ${JSON.stringify(classes)}`, shortCircuit:true}; + }, +}); +const { GameTable } = await import('../components/game-table'); +aliases.deregister(); + +const sourceSector = territory('the_great_flat').sectors[0]; +const sourceLocation = location('the_great_flat', sourceSector); +/** Staged Basic Ix worm decision, matching the focused runtime fixture; only + * forces, source, storm and mobile placement are staged, not a played journey. */ +function fixture(storm: number): Game { + const game = createGame('WORMHMSUI', newPlayer('f', 'Fremen', 'fremen'), false, ['ix']); + game.players.push(newPlayer('i', 'Ixians', 'ixians'), newPlayer('e', 'Emperor', 'emperor')); + Object.assign(game, {status:'playing', phase:1, turn:2, storm, active:'f', order:['f','i','e'], + spiceSequence:{pile:1, skipped:[]}, wormRides:[], + decision:{kind:'wormRide', player:'f', territory:'the_great_flat'}, + mobileStronghold:{location:'arsunt:11'}}); + for (const player of game.players) Object.assign(player, {hand:[], forces:{}, reserves:20, spice:10}); + game.players[0].forces = {[sourceLocation]:4}; game.players[0].reserves = 16; + return game; +} +function render(game: Game) { + const config = {next:0, indices, source:sourceLocation, destination:MOBILE_STRONGHOLD}; + (globalThis as unknown as {__duneWormRideHarness: typeof config}).__duneWormRideHarness = config; + try { + return renderToStaticMarkup(createElement(GameTable, {game:viewGame(game,'f'),busy:false, + send:async () => { assert.fail('Server rendering must not send a decision.'); },onExit() {}})); + } finally { + Reflect.deleteProperty(globalThis, '__duneWormRideHarness'); + } +} +function rideButton(html: string) { + const match = html.match(/]*>Ride Shai-Hulud/); assert.ok(match, 'The original Ride button renders'); + return match[0]; +} + +void test('selected mobile interior blocks a worm ride through a storm-covered pointer with a readable reason', () => { + const game = fixture(11), before = structuredClone(game), html = render(game); + assert.match(html, /Hidden Mobile Stronghold points into the storm; a worm ride cannot enter it\./); + assert.match(rideButton(html), /\bdisabled=""/); + assert.match(html, /