Count mobile stronghold passengers for spice collection
This commit is contained in:
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);
|
||||
});
|
||||
Reference in new issue
Block a user