diff --git a/components/mobile-stronghold.tsx b/components/mobile-stronghold.tsx index 22ab6110..4a927404 100644 --- a/components/mobile-stronghold.tsx +++ b/components/mobile-stronghold.tsx @@ -6,13 +6,13 @@ import { HelpTip } from './help-tip'; import { GRAPH, TERRITORIES, - MOBILE_LOCATION, mobileRouteDistance, splitLocation, territory, } from '@/game/board'; import type { Action, GameView } from '@/game/engine'; import { quoteSandmasterStronghold } from '@/game/sandmaster-movement'; +import { mobileStrongholdCollectionCapacity } from '@/game/mobile-stronghold-collection'; const label = (key: string) => { const loc = splitLocation(key); return `${territory(loc.territory).name}${loc.sector ? ` · sector ${loc.sector}` : ''}`; @@ -56,9 +56,10 @@ export function MobileStronghold({ return chosen === false ? [] : [typeof chosen === 'string' && keys.includes(chosen) ? chosen : keys[0]]; }) : []; const remaining: Record = {}; + const capacity = mobileStrongholdCollectionCapacity(g.players); const collections = route.map((key, index) => { const before = remaining[key] ?? Math.max(0, (g.spice[key] ?? 0) - Number(skillPiles.includes(key))); - const maximum = Math.min(before, (me.forces[MOBILE_LOCATION] ?? 0) * 2); + const maximum = Math.min(before, capacity); const editKey = `${index}:${key}`; const raw = collectionEdits[editKey]; const selected = raw === undefined ? maximum : Number(raw); diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 3d1ac0fc..db05ae61 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,17 @@ open mode gates. ## Current checkpoint and work +**Hidden Mobile Stronghold passenger collection — 28 October. Prototyped / +Partial.** Explicit route choices count foreign fighters and one per deployed +No-Field, excluding advisors; only Ixians receive the spice. The count uses +public state and does not read the concealed marker value. Omitted selections +retain the prior Ixian-only automatic limit. Four related unit files pass 17 +cases, including genuine Basic and Advanced movement with conserved staged +passengers, rejected overcollection and the older default. The control harness +also exercises projected No-Fields and advisors. The foreign-passenger and +advisor treatment is provisional; wider combinations and deployed acceptance +remain open. [Scope](IX_PROTOTYPE.md#hidden-mobile-stronghold-passenger-collection--28-october-2026). + **Advanced Hajr after a deal payment — 28 October. Prototyped / Partial.** A previously consented bribe triggered by another seat's final acceptance no longer closes the payer's immediate Hajr window; the payer's own voluntary diff --git a/docs/IX_PROTOTYPE.md b/docs/IX_PROTOTYPE.md index f60bee28..9d424cd8 100644 --- a/docs/IX_PROTOTYPE.md +++ b/docs/IX_PROTOTYPE.md @@ -1,5 +1,29 @@ # Ixians & Tleilaxu functional prototype +## Hidden Mobile Stronghold passenger collection — 28 October 2026 + +**Prototyped / Partial.** The publisher's E1 rulebook, printed p. 9, permits +Ixians to collect two spice per force in their stronghold without explicitly +resolving foreign passengers. This prototype provisionally includes foreign +fighters, while Ixians alone receive the spice. The supplied Advanced revision, +physical p. 36 / printed p. 34, treats a deployed No-Field as one force for +effects including collection; its concealed value is not used. It also says +advisors have no effect on other factions (physical p. 28 / printed p. 26), so +advisors are provisionally excluded, including a marker controlled by an +advisor. These applications are interpretations, not explicit publisher +clarifications. Normal and special relocation selections use the shared public +capacity; old actions and pending moves without selections retain their old +Ixian-only automatic limit. This avoids changing the result of a saved choice. + +The shared [capacity helper](../game/mobile-stronghold-collection.ts) reads only +public positions and stances. [Native movement cases](../tests/mobile-stronghold-passengers-runtime.test.ts) +exercise Basic and Advanced declarations, a real response, collection and the +legacy default with conserved staged counters and spice. [Helper cases](../tests/mobile-stronghold-passengers.test.ts) +cover advisor-controlled and concealed No-Fields; [control cases](../tests/mobile-stronghold-collection-controls.test.tsx) +exercise projected states in a component harness. Four related files pass 17 +cases; this does not certify every movement interaction or deployed play. +The production build and `npm run check:quick` also passed for this batch. + ## Hidden Mobile Stronghold route collection choices — 27 October 2026 **Prototyped / Partial.** The printed Ixian power permits optional collection @@ -7,8 +31,8 @@ when the occupied stronghold moves into, from or through a spice sector, at two spice per force in the stronghold. See publisher E1 printed p. 9 and the user-authorized revised source physical p. 34 / printed p. 32. The publisher does not spell out partial allocations; this preview provisionally permits any -whole amount up to the live pile and the existing two-per-Ixian-force limit in -each visited sector. How foreign passengers affect that limit remains open. +whole amount up to the live pile and the eligible force limit in each visited +sector. The passenger interpretation and legacy behavior are described above. The existing unique-sector route, storm and destination constraints remain. The controls show starting and subsequent route sectors and permit zero or a diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index edee87e2..33fb217d 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,14 @@ # Rule decision index +## Mobile Stronghold passenger collection — 28 October 2026 + +The E1 Ixian power permits collection per force in the stronghold without +explicitly resolving ownership. Count foreign fighters provisionally, crediting +Ixians alone; exclude advisors based on the supplied Advanced revision's rule +that advisors have no effect on other factions. A deployed No-Field counts as +one, unless its controller is an advisor. This is an interpretation, not a +publisher clarification. [Scope and source pages](IX_PROTOTYPE.md#hidden-mobile-stronghold-passenger-collection--28-october-2026). + ## Deal-triggered payment and Advanced Hajr — 28 October 2026 The supplied revised rulebook's Hajr errata, physical p. 39 (printed p. 37), diff --git a/game/engine.ts b/game/engine.ts index 92f76a70..c809879a 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -12,6 +12,7 @@ import { createVictoryContinuation, validateVictoryContinuation, voteVictoryCont VictoryContinuationError, MAX_VICTORY_EXTRA_STRONGHOLDS, type VictoryContinuation, type VictoryContinuationResume } from './victory-continuation'; import { SabotageGiftError, quoteSabotageGift, quoteSabotageGiftDiscard } from './sabotage-gift'; +import { mobileStrongholdCollectionCapacity } from './mobile-stronghold-collection'; import { kullDefaultEligible } from './kull-default-profile'; import { advancedKullLobbyEligible } from './kull-advanced-profile'; import { SaphoTruthOrderError, quoteSaphoTruthOrder, reorderSaphoTruthOrder, validateSaphoTruthOrder } from './sapho-truth-order'; @@ -38076,7 +38077,7 @@ function validateMobileMove(g: Game, p: Player, input: unknown, max: number) { type MobileCollection = { location: string; amount: number }; /** Quote each route occurrence against the remaining physical pile after any * Sandmaster collection. Absent selections retain the older automatic route. */ -function quoteMobileCollections(g: Game, p: Player, route: string[], input: unknown, +function quoteMobileCollections(g: Game, route: string[], input: unknown, collect: boolean, skillPiles: readonly string[]): MobileCollection[] | undefined { if (input === undefined) return undefined; requireRule(Array.isArray(input) && input.length === route.length, @@ -38090,7 +38091,7 @@ function quoteMobileCollections(g: Game, p: Player, route: string[], input: unkn const available = Math.max(0, remaining[key] ?? 0); requireRule(Object.keys(item).sort().join(',') === 'amount,location' && item.location === key && typeof item.amount === 'number' && Number.isSafeInteger(item.amount) && item.amount >= 0 && - item.amount <= (collect ? Math.min(available, at(p, MOBILE_STRONGHOLD) * 2) : 0), + item.amount <= (collect ? Math.min(available, mobileStrongholdCollectionCapacity(g.players)) : 0), 'Choose an available spice amount within the stronghold forces’ collection limit for each route sector.'); remaining[key] = available - item.amount; return { location: key, amount: item.amount }; @@ -38108,7 +38109,7 @@ function relocateMobileStronghold( : sandmasterLeader(g, p.id) ? Object.values(sandmasterCollectionPiles(g, { interior: move.route })).filter(piles => piles.length === 1).map(piles => piles[0]) : []; - const collections = quoteMobileCollections(g, p, move.route, move.collections, move.collect, skillPiles); + const collections = quoteMobileCollections(g, move.route, move.collections, move.collect, skillPiles); let collected = 0; if (move.collect) { // User ruling 7 October 2026: passengers remaining inside a relocated HMS @@ -39405,7 +39406,7 @@ export function prepareSpecialKaramaIntent( const route = validateMobileMove(g, p, action.route, 2); const sandmasterPiles = nexusRule(() => validateSandmasterStronghold( g, p.id, route, action.sandmasterPiles, action.collect !== false)); - const collections = quoteMobileCollections(g, p, route, action.collections, action.collect !== false, sandmasterPiles); + const collections = quoteMobileCollections(g, route, action.collections, action.collect !== false, sandmasterPiles); return { ...base, kind: 'ixians', @@ -42120,7 +42121,7 @@ function applyActionInner( const route = validateMobileMove(g, p, action.route, 3); const sandmasterPiles = nexusRule(() => validateSandmasterStronghold( g, p.id, route, action.sandmasterPiles, action.collect !== false)); - const collections = quoteMobileCollections(g, p, route, action.collections, action.collect !== false, sandmasterPiles); + const collections = quoteMobileCollections(g, route, action.collections, action.collect !== false, sandmasterPiles); g.pendingMobileMove = { player: p.id, route, diff --git a/game/mobile-stronghold-collection.ts b/game/mobile-stronghold-collection.ts new file mode 100644 index 00000000..d2cbd6bf --- /dev/null +++ b/game/mobile-stronghold-collection.ts @@ -0,0 +1,40 @@ +import { isAdvisor } from './advisors'; +import { MOBILE_STRONGHOLD, splitLocation } from './board'; +import type { Player } from './engine'; + +/** Only public board presence is needed; concealed token values are never read. */ +export type MobileCollectionPlayer = Pick & { + forces: Readonly>; + noField?: { + deployed: { + controller: string; + location: { territory: string; sector: number }; + } | null; + } | null; +}; + +/** + * Provisional interpretation: all fighters aboard contribute, including + * passengers; advisors do not. A concealed No-Field contributes one force, + * with its controller's stance, even though Richese holds the marker record. + */ +export function mobileStrongholdCollectionCapacity( + players: readonly MobileCollectionPlayer[], +): number { + let fighters = 0; + for (const player of players) { + if (!isAdvisor(player, MOBILE_STRONGHOLD)) { + fighters += Object.entries(player.forces).reduce( + (total, [key, count]) => + total + (splitLocation(key).territory === MOBILE_STRONGHOLD ? count : 0), + 0, + ); + } + const marker = player.noField?.deployed; + if (marker?.location.territory === MOBILE_STRONGHOLD) { + const controller = players.find((seat) => seat.id === marker.controller); + if (!controller || !isAdvisor(controller, MOBILE_STRONGHOLD)) fighters++; + } + } + return fighters * 2; +} diff --git a/game/reference.ts b/game/reference.ts index b7a01346..63768180 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -59,7 +59,7 @@ export const HELP = { 'Ixians privately choose a starting card before the rest are shuffled and dealt. Before bidding, inspect one extra card and return one to the top or bottom, then shuffle the auction pool. Advanced Ixians can swap one upcoming card with a hand card before Atreides looks, once per round. An ally may replace the card it just purchased with the deck’s top card. Each bidding advantage has its own Karama response before the effect; canceled inspection reveals nothing.', mobileStronghold: - 'The Hidden Mobile Stronghold is a separate stronghold whose occupants survive storms and worms. It cannot relocate into, out of or through the storm; battles wait while its pointing sector is in storm. After the first storm, Ixians place it at a non-stronghold sector. While Ixian forces occupy it, they may relocate it up to three territories before later storm dials or reveals. Declare the sector route to collect spice along it. Other factions enter by movement through the pointing sector; only Ixians ship directly inside, with Bene Gesserit accompaniment allowed. It counts toward victory for its controller. Further movement and collection interaction audits are pending.', + 'The Hidden Mobile Stronghold is a separate stronghold whose occupants survive storms and worms. It cannot relocate into, out of or through the storm; battles wait while its pointing sector is in storm. After the first storm, Ixians place it at a non-stronghold sector. While Ixian forces occupy it, they may relocate it up to three territories before later storm dials or reveals. Declare the sector route and select up to two spice per eligible force in the stronghold at each visited pile; Ixians collect it. The preview provisionally counts foreign fighters and a concealed No-Field as one force, but excludes advisors. Other factions enter by movement through the pointing sector; only Ixians ship directly inside, with Bene Gesserit accompaniment allowed. It counts toward victory for its controller. Further movement and collection interaction audits are pending.', ixTransport: 'A selected cyborg lets its movement group travel two territories; a cyborg left behind does not. Ornithopters allow three. Cyborgs collect three spice each and cost three to revive; suboids collect normally and cost two. Choose either type for the free revival.', diff --git a/tests/mobile-stronghold-collection-controls.test.tsx b/tests/mobile-stronghold-collection-controls.test.tsx index 4fdda414..8dd888b8 100644 --- a/tests/mobile-stronghold-collection-controls.test.tsx +++ b/tests/mobile-stronghold-collection-controls.test.tsx @@ -7,6 +7,8 @@ import * as jsxRuntime from 'react/jsx-runtime'; import ts from 'typescript'; import * as board from '../game/board'; import { createGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine'; +import { mobileStrongholdCollectionCapacity } from '../game/mobile-stronghold-collection'; +import { createRicheseNoField, deployRicheseNoField } from '../game/richese-no-field'; import type { MobileStronghold } from '../components/mobile-stronghold'; type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>; @@ -31,7 +33,7 @@ function fixture() { board.territory(board.splitLocation(key).territory).type !== 'stronghold'); assert.ok(next); g.spice = { 'polar_sink:0': 3, [next]: 20 }; - return { view: viewGame(g, 'i'), next }; + return { game: g, view: viewGame(g, 'i'), next }; } function controller(game: GameView, sandmasterPile?: string, card?: string) { @@ -53,6 +55,7 @@ function controller(game: GameView, sandmasterPile?: string, card?: string) { if (name === '@/components/ui/input') return { Input: 'input' }; if (name === './help-tip') return { HelpTip: () => null }; if (name === '@/game/board') return board; + if (name === '@/game/mobile-stronghold-collection') return { mobileStrongholdCollectionCapacity }; if (name === '@/game/sandmaster-movement') return { quoteSandmasterStronghold: () => sandmasterPile ? { piles: { [board.splitLocation(sandmasterPile).territory]: [sandmasterPile] } } : null }; throw new Error(`Unexpected import ${name}`); @@ -120,3 +123,33 @@ void test('controlled Sandmaster pile is debited before the route input cap', () assert.deepEqual(action.sandmasterPiles, [next]); assert.deepEqual(action.collections, [{ location: 'polar_sink:0', amount: 3 }, { location: next, amount: 2 }]); }); + +void test('route capacity includes foreign fighters and a public No-Field, but excludes advisors', () => { + for (const tokenId of ['hidden-zero', 'hidden-five']) { + const { game, next } = fixture(); + game.advanced = true; + game.players[1].forces = { [board.MOBILE_LOCATION]: 2 }; + const bg = newPlayer('b', 'Bene Gesserit', 'beneGesserit'); + bg.forces = { [board.MOBILE_LOCATION]: 4 }; + bg.advisors = { [board.MOBILE_STRONGHOLD]: {} }; + const richese = newPlayer('r', 'Richese', 'richese'); + richese.noField = deployRicheseNoField( + createRicheseNoField(['hidden-zero', 'hidden-three', 'hidden-five']), + { tokenId, controller: richese.id, location: { territory: board.MOBILE_STRONGHOLD, sector: 0 } }, + ); + game.players.push(bg, richese); + game.spice[next] = 40; + const view = viewGame(game, 'i'); + const publicMarker = view.players.find(player => player.id === 'r')!.noField; + assert.ok(publicMarker?.deployed); + assert.equal('private' in publicMarker, false); + assert.equal('tokenId' in publicMarker.deployed, false); + const ui = controller(view); ui.chooseNext(next); + // Six Ixians + two foreign fighters + one No-Field, two spice each. + assert.equal(ui.inputs()[1].props.max, 18); + assert.equal(ui.inputs()[1].props.value, 18); + delete bg.advisors[board.MOBILE_STRONGHOLD]; + const fighters = controller(viewGame(game, 'i')); fighters.chooseNext(next); + assert.equal(fighters.inputs()[1].props.max, 26); + } +}); diff --git a/tests/mobile-stronghold-passengers-runtime.test.ts b/tests/mobile-stronghold-passengers-runtime.test.ts new file mode 100644 index 00000000..d37dc565 --- /dev/null +++ b/tests/mobile-stronghold-passengers-runtime.test.ts @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { type Game } from '../game/engine'; +import { MOBILE_LOCATION } from '../game/board'; +import { createSemutaIxianMovementFixture as fixture, actSemutaIxianMovement as act, + allowSemutaIxianMovement as allow, semutaIxianMovementPlayer as player, + semutaIxianMovementInventory as inventory } from './fixture-semuta-ixian-movement'; + +function spiceTotal(game: Game) { + return Object.values(game.spice).reduce((sum, amount) => sum + amount, 0) + + game.players.reduce((sum, p) => sum + p.spice, 0); +} +function forceTotals(game: Game) { + return game.players.map(p => ({ id:p.id, + total:p.reserves + p.tanks + Object.values(p.forces).reduce((sum, amount) => sum + amount, 0), + elites:p.elites ? p.elites.reserves + p.elites.tanks + + Object.values(p.elites.forces).reduce((sum, amount) => sum + amount, 0) : 0 })); +} +function selections(route: readonly string[], pile: string, amount: number) { + return route.map(location => ({ location, amount:location === pile ? amount : 0 })); +} + +for (const advanced of [false, true]) { + const mode = advanced ? 'Advanced' : 'Basic'; + void test(`${mode} native HMS collection counts foreign passengers for explicit choices while preserving legacy fallback`, () => { + const f = fixture({ kind:'mobileStronghold', advanced }); + const source = structuredClone(f.beforeDeclaration), route = f.route!, pile = f.collection[0].location; + const originalForces = forceTotals(source), originalSpice = spiceTotal(source); + const ix = player(source, f.owner), foreign = player(source, 'atreides'); + + // Stage one Ixian and two foreign fighters aboard using existing counters. + assert.equal(ix.forces[MOBILE_LOCATION], 6); + assert.equal(ix.elites!.forces[MOBILE_LOCATION], 3); + ix.forces[MOBILE_LOCATION] = 1; ix.reserves += 5; + ix.elites!.forces[MOBILE_LOCATION] = 1; ix.elites!.reserves += 2; + assert.ok(foreign.reserves >= 2); + foreign.reserves -= 2; + foreign.forces[MOBILE_LOCATION] = (foreign.forces[MOBILE_LOCATION] ?? 0) + 2; + assert.equal(foreign.forces[MOBILE_LOCATION], 2); + + // Concentrate existing spice on a visited sand sector. If the native blow + // is too small, transfer the difference from a player's existing supply. + const boardSpice = Object.values(source.spice).reduce((sum, amount) => sum + amount, 0); + source.spice = { [pile]:boardSpice }; + if (boardSpice < 7) { + const difference = 7 - boardSpice; + const donor = source.players.find(p => p.spice >= difference); + assert.ok(donor); + donor.spice -= difference; source.spice[pile] += difference; + } + assert.equal(spiceTotal(source), originalSpice); + assert.deepEqual(forceTotals(source), originalForces); + const before = structuredClone(source); + + assert.throws(() => act(source, f.owner, { type:'decision', route, collect:true, + collections:selections(route, pile, 7) })); + assert.deepEqual(source, before, 'Rejected choices do not mutate the game'); + + let pending = act(source, f.owner, { type:'decision', route, collect:true, + collections:selections(route, pile, 6) }); + assert.equal(pending.response?.kind, 'mobileStronghold'); + assert.deepEqual(pending.spice, source.spice, 'A cancelable move does not collect yet'); + let done = allow(pending); + assert.equal(player(done, f.owner).spice, player(source, f.owner).spice + 6); + assert.equal(player(done, 'atreides').spice, player(source, 'atreides').spice); + assert.equal(done.spice[pile] ?? 0, source.spice[pile] - 6); + assert.equal(spiceTotal(done), originalSpice); + assert.deepEqual(forceTotals(done), originalForces); + assert.deepEqual(inventory(done), f.originalInventory); + + // Omitted allocations retain the historical Ixian-only fallback for old + // clients and queued moves, even though explicit choices count passengers. + pending = act(source, f.owner, { type:'decision', route, collect:true }); + done = allow(pending); + assert.equal(player(done, f.owner).spice, player(source, f.owner).spice + 2); + assert.equal(done.spice[pile] ?? 0, source.spice[pile] - 2); + assert.equal(spiceTotal(done), originalSpice); + assert.deepEqual(forceTotals(done), originalForces); + assert.deepEqual(inventory(done), f.originalInventory); + }); +} diff --git a/tests/mobile-stronghold-passengers.test.ts b/tests/mobile-stronghold-passengers.test.ts new file mode 100644 index 00000000..847e8722 --- /dev/null +++ b/tests/mobile-stronghold-passengers.test.ts @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { MOBILE_LOCATION, MOBILE_STRONGHOLD } from '../game/board'; +import { + mobileStrongholdCollectionCapacity as capacity, + type MobileCollectionPlayer, +} from '../game/mobile-stronghold-collection'; + +const ix: MobileCollectionPlayer = { + id: 'ix', faction: 'ixians', forces: { [MOBILE_LOCATION]: 2, arrakeen: 4 }, +}; +const fremen: MobileCollectionPlayer = { + id: 'fremen', faction: 'fremen', forces: { [MOBILE_LOCATION]: 3 }, +}; +const bg: MobileCollectionPlayer = { + id: 'bg', faction: 'beneGesserit', forces: { [MOBILE_LOCATION]: 4 }, + advisors: { [MOBILE_STRONGHOLD]: {} }, +}; + +void test('mobile spice capacity includes foreign fighters and excludes advisors', () => { + assert.equal(capacity([ix, fremen, bg]), 10); + assert.equal(capacity([ix, fremen, { ...bg, advisors: {} }]), 18); + assert.equal(capacity([]), 0); +}); + +void test('mobile spice capacity treats a concealed No-Field as one without reading its identity or value', () => { + const deployed = { + controller: 'fremen', + location: { territory: MOBILE_STRONGHOLD, sector: 0 }, + get tokenId(): string { throw new Error('concealed token read'); }, + get value(): number { throw new Error('concealed value read'); }, + }; + const richese: MobileCollectionPlayer = { + id: 'richese', faction: 'richese', forces: {}, noField: { deployed }, + }; + assert.equal(capacity([ix, fremen, richese]), 12); + assert.equal(capacity([ix, fremen, { ...richese, noField: { deployed: null } }]), 10); + assert.equal(capacity([ix, fremen, { + ...richese, + noField: { deployed: { controller: 'richese', location: { territory: 'arrakeen', sector: 9 } } }, + }]), 10); +}); + +void test('allied No-Field uses the controlling faction advisor stance', () => { + const richese: MobileCollectionPlayer = { + id: 'richese', faction: 'richese', forces: {}, + noField: { deployed: { controller: 'bg', location: { territory: MOBILE_STRONGHOLD, sector: 0 } } }, + }; + assert.equal(capacity([ix, bg, richese]), 4); + assert.equal(capacity([ix, { ...bg, advisors: {} }, richese]), 14); +});