Count mobile stronghold passengers for spice collection

This commit is contained in:
Quintine committed 2026-10-28 01:03:06 +11:00
1 parent 3edebecc89
commit 7d26929abd
10 files changed
+262 -11

No files matched your search

+3 -2
View File
@@ -6,13 +6,13 @@ import { HelpTip } from './help-tip';
import { import {
GRAPH, GRAPH,
TERRITORIES, TERRITORIES,
MOBILE_LOCATION,
mobileRouteDistance, mobileRouteDistance,
splitLocation, splitLocation,
territory, territory,
} from '@/game/board'; } from '@/game/board';
import type { Action, GameView } from '@/game/engine'; import type { Action, GameView } from '@/game/engine';
import { quoteSandmasterStronghold } from '@/game/sandmaster-movement'; import { quoteSandmasterStronghold } from '@/game/sandmaster-movement';
import { mobileStrongholdCollectionCapacity } from '@/game/mobile-stronghold-collection';
const label = (key: string) => { const label = (key: string) => {
const loc = splitLocation(key); const loc = splitLocation(key);
return `${territory(loc.territory).name}${loc.sector ? ` · sector ${loc.sector}` : ''}`; 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]]; return chosen === false ? [] : [typeof chosen === 'string' && keys.includes(chosen) ? chosen : keys[0]];
}) : []; }) : [];
const remaining: Record<string, number> = {}; const remaining: Record<string, number> = {};
const capacity = mobileStrongholdCollectionCapacity(g.players);
const collections = route.map((key, index) => { const collections = route.map((key, index) => {
const before = remaining[key] ?? Math.max(0, (g.spice[key] ?? 0) - Number(skillPiles.includes(key))); 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 editKey = `${index}:${key}`;
const raw = collectionEdits[editKey]; const raw = collectionEdits[editKey];
const selected = raw === undefined ? maximum : Number(raw); const selected = raw === undefined ? maximum : Number(raw);
+11
View File
@@ -16,6 +16,17 @@ open mode gates.
## Current checkpoint and work ## 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 **Advanced Hajr after a deal payment — 28 October. Prototyped / Partial.** A
previously consented bribe triggered by another seat's final acceptance no previously consented bribe triggered by another seat's final acceptance no
longer closes the payer's immediate Hajr window; the payer's own voluntary longer closes the payer's immediate Hajr window; the payer's own voluntary
+26 -2
View File
@@ -1,5 +1,29 @@
# Ixians & Tleilaxu functional prototype # 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 ## Hidden Mobile Stronghold route collection choices — 27 October 2026
**Prototyped / Partial.** The printed Ixian power permits optional collection **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 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 user-authorized revised source physical p. 34 / printed p. 32. The publisher
does not spell out partial allocations; this preview provisionally permits any 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 whole amount up to the live pile and the eligible force limit in each visited
each visited sector. How foreign passengers affect that limit remains open. sector. The passenger interpretation and legacy behavior are described above.
The existing unique-sector route, storm and destination constraints remain. The existing unique-sector route, storm and destination constraints remain.
The controls show starting and subsequent route sectors and permit zero or a The controls show starting and subsequent route sectors and permit zero or a
+9
View File
@@ -1,5 +1,14 @@
# Rule decision index # 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 ## Deal-triggered payment and Advanced Hajr — 28 October 2026
The supplied revised rulebook's Hajr errata, physical p. 39 (printed p. 37), The supplied revised rulebook's Hajr errata, physical p. 39 (printed p. 37),
+6 -5
View File
@@ -12,6 +12,7 @@ import { createVictoryContinuation, validateVictoryContinuation, voteVictoryCont
VictoryContinuationError, MAX_VICTORY_EXTRA_STRONGHOLDS, type VictoryContinuation, VictoryContinuationError, MAX_VICTORY_EXTRA_STRONGHOLDS, type VictoryContinuation,
type VictoryContinuationResume } from './victory-continuation'; type VictoryContinuationResume } from './victory-continuation';
import { SabotageGiftError, quoteSabotageGift, quoteSabotageGiftDiscard } from './sabotage-gift'; import { SabotageGiftError, quoteSabotageGift, quoteSabotageGiftDiscard } from './sabotage-gift';
import { mobileStrongholdCollectionCapacity } from './mobile-stronghold-collection';
import { kullDefaultEligible } from './kull-default-profile'; import { kullDefaultEligible } from './kull-default-profile';
import { advancedKullLobbyEligible } from './kull-advanced-profile'; import { advancedKullLobbyEligible } from './kull-advanced-profile';
import { SaphoTruthOrderError, quoteSaphoTruthOrder, reorderSaphoTruthOrder, validateSaphoTruthOrder } from './sapho-truth-order'; 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 }; type MobileCollection = { location: string; amount: number };
/** Quote each route occurrence against the remaining physical pile after any /** Quote each route occurrence against the remaining physical pile after any
* Sandmaster collection. Absent selections retain the older automatic route. */ * 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 { collect: boolean, skillPiles: readonly string[]): MobileCollection[] | undefined {
if (input === undefined) return undefined; if (input === undefined) return undefined;
requireRule(Array.isArray(input) && input.length === route.length, 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); const available = Math.max(0, remaining[key] ?? 0);
requireRule(Object.keys(item).sort().join(',') === 'amount,location' && item.location === key && requireRule(Object.keys(item).sort().join(',') === 'amount,location' && item.location === key &&
typeof item.amount === 'number' && Number.isSafeInteger(item.amount) && item.amount >= 0 && 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.'); 'Choose an available spice amount within the stronghold forces’ collection limit for each route sector.');
remaining[key] = available - item.amount; remaining[key] = available - item.amount;
return { location: key, amount: item.amount }; return { location: key, amount: item.amount };
@@ -38108,7 +38109,7 @@ function relocateMobileStronghold(
: sandmasterLeader(g, p.id) : sandmasterLeader(g, p.id)
? Object.values(sandmasterCollectionPiles(g, { interior: move.route })).filter(piles => piles.length === 1).map(piles => piles[0]) ? 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; let collected = 0;
if (move.collect) { if (move.collect) {
// User ruling 7 October 2026: passengers remaining inside a relocated HMS // 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 route = validateMobileMove(g, p, action.route, 2);
const sandmasterPiles = nexusRule(() => validateSandmasterStronghold( const sandmasterPiles = nexusRule(() => validateSandmasterStronghold(
g, p.id, route, action.sandmasterPiles, action.collect !== false)); 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 { return {
...base, ...base,
kind: 'ixians', kind: 'ixians',
@@ -42120,7 +42121,7 @@ function applyActionInner(
const route = validateMobileMove(g, p, action.route, 3); const route = validateMobileMove(g, p, action.route, 3);
const sandmasterPiles = nexusRule(() => validateSandmasterStronghold( const sandmasterPiles = nexusRule(() => validateSandmasterStronghold(
g, p.id, route, action.sandmasterPiles, action.collect !== false)); 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 = { g.pendingMobileMove = {
player: p.id, player: p.id,
route, route,
+40
View File
@@ -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<Player, 'id' | 'faction' | 'advisors'> & {
forces: Readonly<Record<string, number>>;
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;
}
+1 -1
View File
@@ -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.', '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: 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: 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.', '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.',
@@ -7,6 +7,8 @@ import * as jsxRuntime from 'react/jsx-runtime';
import ts from 'typescript'; import ts from 'typescript';
import * as board from '../game/board'; import * as board from '../game/board';
import { createGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine'; 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'; import type { MobileStronghold } from '../components/mobile-stronghold';
type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>; type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>;
@@ -31,7 +33,7 @@ function fixture() {
board.territory(board.splitLocation(key).territory).type !== 'stronghold'); board.territory(board.splitLocation(key).territory).type !== 'stronghold');
assert.ok(next); assert.ok(next);
g.spice = { 'polar_sink:0': 3, [next]: 20 }; 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) { 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 === '@/components/ui/input') return { Input: 'input' };
if (name === './help-tip') return { HelpTip: () => null }; if (name === './help-tip') return { HelpTip: () => null };
if (name === '@/game/board') return board; if (name === '@/game/board') return board;
if (name === '@/game/mobile-stronghold-collection') return { mobileStrongholdCollectionCapacity };
if (name === '@/game/sandmaster-movement') return { quoteSandmasterStronghold: () => sandmasterPile if (name === '@/game/sandmaster-movement') return { quoteSandmasterStronghold: () => sandmasterPile
? { piles: { [board.splitLocation(sandmasterPile).territory]: [sandmasterPile] } } : null }; ? { piles: { [board.splitLocation(sandmasterPile).territory]: [sandmasterPile] } } : null };
throw new Error(`Unexpected import ${name}`); 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.sandmasterPiles, [next]);
assert.deepEqual(action.collections, [{ location: 'polar_sink:0', amount: 3 }, { location: next, amount: 2 }]); 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);
}
});
@@ -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);
});
}
@@ -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);
});