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

@@ -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);
}
});
@@ -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);
});