Allow selective mobile stronghold spice collection
This commit is contained in:
1 parent
31b3c1ab87
commit
4e55ab9e86
11 files changed
+394
-16
No files matched your search
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { HelpTip } from './help-tip';
|
||||
import {
|
||||
GRAPH,
|
||||
@@ -33,6 +34,7 @@ export function MobileStronghold({
|
||||
const [destination, setDestination] = useState('polar_sink:0');
|
||||
const [route, setRoute] = useState<string[]>(pointer ? [pointer] : []);
|
||||
const [collect, setCollect] = useState(true);
|
||||
const [collectionEdits, setCollectionEdits] = useState<Record<string, string>>({});
|
||||
const [skillSelections, setSkillSelections] = useState<Record<string, string | false>>({});
|
||||
const max = card ? 2 : 3;
|
||||
const current = route.at(-1) ?? '';
|
||||
@@ -53,17 +55,23 @@ export function MobileStronghold({
|
||||
const chosen = skillSelections[id];
|
||||
return chosen === false ? [] : [typeof chosen === 'string' && keys.includes(chosen) ? chosen : keys[0]];
|
||||
}) : [];
|
||||
let preview = 0;
|
||||
const remaining: Record<string, number> = {};
|
||||
for (const key of route) {
|
||||
const collections = route.map((key, index) => {
|
||||
const before = remaining[key] ?? Math.max(0, (g.spice[key] ?? 0) - Number(skillPiles.includes(key)));
|
||||
const amount = Math.min(before, (me.forces[MOBILE_LOCATION] ?? 0) * 2);
|
||||
const maximum = Math.min(before, (me.forces[MOBILE_LOCATION] ?? 0) * 2);
|
||||
const editKey = `${index}:${key}`;
|
||||
const raw = collectionEdits[editKey];
|
||||
const selected = raw === undefined ? maximum : Number(raw);
|
||||
const legal = !collect || (raw !== '' && Number.isSafeInteger(selected) && selected >= 0 && selected <= maximum);
|
||||
const amount = collect && legal ? selected : 0;
|
||||
remaining[key] = before - amount;
|
||||
preview += amount;
|
||||
}
|
||||
return { location: key, editKey, amount, maximum, raw, legal };
|
||||
});
|
||||
const preview = collections.reduce((total, choice) => total + choice.amount, 0);
|
||||
const valid =
|
||||
placement ||
|
||||
(!blocked && route.length > 1 &&
|
||||
mobileRouteDistance(route) <= max &&
|
||||
route.every((key) => splitLocation(key).sector !== g.storm) &&
|
||||
territory(splitLocation(current).territory).type !== 'stronghold');
|
||||
return (
|
||||
@@ -84,10 +92,24 @@ export function MobileStronghold({
|
||||
{!placement && (
|
||||
<>
|
||||
<ol>
|
||||
{route.map((key) => (
|
||||
<li key={key}>
|
||||
{label(key)}
|
||||
{g.spice[key] ? ` · ${g.spice[key]} spice` : ''}
|
||||
{collections.map((choice, index) => (
|
||||
<li key={choice.editKey}>
|
||||
<label>
|
||||
{label(choice.location)} · {index === 0 ? 'starting sector' : `route step ${index}`}
|
||||
{' '}· collect up to {choice.maximum} spice
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={choice.maximum}
|
||||
step={1}
|
||||
value={collect ? choice.raw ?? choice.amount : 0}
|
||||
disabled={busy || !collect}
|
||||
aria-label={`Spice to collect at ${label(choice.location)}, ${index === 0 ? 'starting sector' : `route step ${index}`}`}
|
||||
aria-invalid={!choice.legal}
|
||||
onChange={(event) => setCollectionEdits({ ...collectionEdits, [choice.editKey]: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
{!choice.legal && <p className="fine">Choose a whole amount from 0 to {choice.maximum}.</p>}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
@@ -160,15 +182,17 @@ export function MobileStronghold({
|
||||
)}
|
||||
<Button
|
||||
className="game-action"
|
||||
disabled={busy || !valid}
|
||||
disabled={busy || !valid || collections.some((choice) => !choice.legal)}
|
||||
onClick={() =>
|
||||
act(
|
||||
placement
|
||||
? { type: 'decision', location: destination }
|
||||
: card
|
||||
? { type: 'card', mode: 'special', card, route, collect,
|
||||
collections: collections.map(({ location, amount }) => ({ location, amount })),
|
||||
...(sandmaster ? { sandmasterPiles: skillPiles } : {}) }
|
||||
: { type: 'decision', route, collect,
|
||||
collections: collections.map(({ location, amount }) => ({ location, amount })),
|
||||
...(sandmaster ? { sandmasterPiles: skillPiles } : {}) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,14 @@ open mode gates.
|
||||
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
|
||||
Prototype labels and final acceptance gates remain.
|
||||
|
||||
**Ixian stronghold route collection choices — 27 October. Prototyped / Partial.**
|
||||
Players can provisionally choose the amount collected from each visited
|
||||
sector, within the current physical-pile and Ixian-force cap. The original
|
||||
cancelable move and Advanced special Karama retain the choices; Sandmaster
|
||||
deductions come first. Missing choice packets keep legacy all-or-none behavior.
|
||||
Foreign-passenger capacity, wider combinations and final/deployed acceptance
|
||||
remain open. [Scope](IX_PROTOTYPE.md#hidden-mobile-stronghold-route-collection-choices--27-october-2026).
|
||||
|
||||
**Basic Truthtrance Storm-dial comparisons — 27 October. Prototyped / Partial.**
|
||||
The upcoming original Basic dial supports exact, at-least and at-most
|
||||
conditions, with multiple answers sharing the native range and older omitted
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 27 October 2026 — Ixian stronghold route collection choices
|
||||
|
||||
The Hidden Mobile Stronghold's route editor supports provisionally selected
|
||||
amounts in each visited sector, with native force/pile limits and Sandmaster
|
||||
deductions. Normal cancellation and Advanced special Karama retain the chosen
|
||||
amounts. Older omitted choices retain automatic maximum or no collection;
|
||||
the minimal bots may use that legal default. Foreign-passenger interpretation,
|
||||
broader interactions and final/deployed acceptance remain open.
|
||||
[Scope](IX_PROTOTYPE.md#hidden-mobile-stronghold-route-collection-choices--27-october-2026).
|
||||
|
||||
## 27 October 2026 — Basic Truthtrance storm-dial comparisons
|
||||
|
||||
Upcoming original Basic Storm dials support exact, at-least and at-most
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
# Ixians & Tleilaxu functional prototype
|
||||
|
||||
## Hidden Mobile Stronghold route collection choices — 27 October 2026
|
||||
|
||||
**Prototyped / Partial.** The printed Ixian power permits optional collection
|
||||
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.
|
||||
The existing unique-sector route, storm and destination constraints remain.
|
||||
|
||||
The controls show starting and subsequent route sectors and permit zero or a
|
||||
partial amount. Sandmaster deductions, if selected, occur first. A cancelable
|
||||
ordinary declaration retains the selected amounts through the real response;
|
||||
Advanced special Karama uses the same selector and two-territory limit. Legacy
|
||||
actions and saved pending moves without choices retain their previous automatic
|
||||
maximum or no-collection behavior. AI may continue using that legal default.
|
||||
[Runtime tests](../tests/mobile-stronghold-collection-runtime.test.ts) use a
|
||||
native source and explicitly conserved staging for the two-pile examples;
|
||||
[control tests](../tests/mobile-stronghold-collection-controls.test.tsx) use
|
||||
a controlled component harness, not a browser. Wider route interpretations,
|
||||
other combinations and deployed acceptance remain open.
|
||||
|
||||
## Advanced starting draft source cutover — 27 October 2026
|
||||
|
||||
**Prototyped / Partial.** The user-authorized Advanced revision, physical34 /
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Rule decision index
|
||||
|
||||
## Ixian stronghold route spice choices — 27 October 2026
|
||||
|
||||
Provisionally let Ixians choose a whole amount from zero through the existing
|
||||
force-limited available pile in each sector of their stronghold route. Printed
|
||||
E1 p. 9 and the authorized revision physical p. 34 allow optional collection;
|
||||
they do not prescribe partial amounts or resolve foreign passenger capacity.
|
||||
The existing route and per-sector capacity interpretation remains. Sandmaster
|
||||
selection precedes the faction collection; legacy all-or-none choices keep
|
||||
their saved meaning. [Scope](IX_PROTOTYPE.md#hidden-mobile-stronghold-route-collection-choices--27-october-2026).
|
||||
|
||||
## Basic Truthtrance storm-dial comparisons — 27 October 2026
|
||||
|
||||
Extend the provisional upcoming-dial question to exact, at-least and at-most
|
||||
|
||||
+36
-4
@@ -2341,6 +2341,7 @@ export type Game = {
|
||||
player: string;
|
||||
route: string[];
|
||||
collect: boolean;
|
||||
collections?: MobileCollection[];
|
||||
sandmasterPiles?: string[];
|
||||
} | null;
|
||||
revivalRules?: RevivalRules;
|
||||
@@ -37858,9 +37859,32 @@ function validateMobileMove(g: Game, p: Player, input: unknown, max: number) {
|
||||
);
|
||||
return route;
|
||||
}
|
||||
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,
|
||||
collect: boolean, skillPiles: readonly string[]): MobileCollection[] | undefined {
|
||||
if (input === undefined) return undefined;
|
||||
requireRule(Array.isArray(input) && input.length === route.length,
|
||||
'Choose a spice amount for every sector in the stronghold route.');
|
||||
const remaining = { ...g.spice };
|
||||
for (const pile of skillPiles) remaining[pile] = (remaining[pile] ?? 0) - 1;
|
||||
return input.map((entry: unknown, index: number) => {
|
||||
requireRule(entry && typeof entry === 'object' && !Array.isArray(entry),
|
||||
'Choose a valid spice amount for each route sector.');
|
||||
const item = entry as Record<string, unknown>, key = route[index];
|
||||
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),
|
||||
'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 };
|
||||
});
|
||||
}
|
||||
function relocateMobileStronghold(
|
||||
g: Game,
|
||||
move: { player: string; route: string[]; collect: boolean; sandmasterPiles?: string[] },
|
||||
move: { player: string; route: string[]; collect: boolean; collections?: MobileCollection[]; sandmasterPiles?: string[] },
|
||||
) {
|
||||
const p = getPlayer(g, move.player);
|
||||
const blocked = homeworldRule(() => homeworldMobileStrongholdMovementBlock(g, p.id));
|
||||
@@ -37870,6 +37894,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);
|
||||
let collected = 0;
|
||||
if (move.collect) {
|
||||
// User ruling 7 October 2026: passengers remaining inside a relocated HMS
|
||||
@@ -37887,8 +37912,8 @@ function relocateMobileStronghold(
|
||||
{ faction: p.faction, name: 'Sandmaster collection' },
|
||||
);
|
||||
}
|
||||
for (const key of move.route) {
|
||||
const amount = Math.min(g.spice[key] ?? 0, at(p, MOBILE_STRONGHOLD) * 2);
|
||||
for (const [index, key] of move.route.entries()) {
|
||||
const amount = collections ? collections[index].amount : Math.min(g.spice[key] ?? 0, at(p, MOBILE_STRONGHOLD) * 2);
|
||||
if (amount) {
|
||||
g.spice[key] -= amount;
|
||||
p.spice += amount;
|
||||
@@ -39002,7 +39027,7 @@ export type SpecialKaramaIntent = {
|
||||
} & (
|
||||
| { kind: 'choam'; cards: string[] }
|
||||
| { kind: 'richese'; acquire: string; nexusEvent?: string }
|
||||
| { kind: 'ixians'; route: string[]; collect: boolean; sandmasterPiles?: string[] }
|
||||
| { kind: 'ixians'; route: string[]; collect: boolean; collections?: MobileCollection[]; sandmasterPiles?: string[] }
|
||||
| { kind: 'tleilaxu'; target: string; revival: PendingRevival | null }
|
||||
| { kind: 'fremen'; territory: string }
|
||||
| {
|
||||
@@ -39165,11 +39190,13 @@ 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);
|
||||
return {
|
||||
...base,
|
||||
kind: 'ixians',
|
||||
route: [...route],
|
||||
collect: action.collect !== false,
|
||||
...(collections !== undefined ? { collections } : {}),
|
||||
...(action.sandmasterPiles !== undefined ? { sandmasterPiles } : {}),
|
||||
};
|
||||
} else if (p.faction === 'tleilaxu') {
|
||||
@@ -39320,6 +39347,7 @@ function specialKaramaAction(intent: SpecialKaramaIntent): Action {
|
||||
return { ...action, cards: [...intent.cards] };
|
||||
case 'ixians':
|
||||
return { ...action, route: [...intent.route], collect: intent.collect,
|
||||
...(intent.collections !== undefined ? { collections: intent.collections.map(item => ({ ...item })) } : {}),
|
||||
...(intent.sandmasterPiles !== undefined ? { sandmasterPiles: [...intent.sandmasterPiles] } : {}) };
|
||||
case 'tleilaxu':
|
||||
case 'atreides':
|
||||
@@ -39520,6 +39548,7 @@ function resolveSpecialKaramaEffect(g: Game, p: Player, card: Card, intent: Spec
|
||||
player: p.id,
|
||||
route,
|
||||
collect: intent.collect,
|
||||
...(intent.collections !== undefined ? { collections: intent.collections } : {}),
|
||||
...(intent.sandmasterPiles !== undefined ? { sandmasterPiles: intent.sandmasterPiles } : {}),
|
||||
});
|
||||
} else if (intent.kind === 'tleilaxu') {
|
||||
@@ -41748,10 +41777,12 @@ 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);
|
||||
g.pendingMobileMove = {
|
||||
player: p.id,
|
||||
route,
|
||||
collect: action.collect !== false,
|
||||
...(collections !== undefined ? { collections } : {}),
|
||||
...(action.sandmasterPiles !== undefined ? { sandmasterPiles } : {}),
|
||||
};
|
||||
g.response = {
|
||||
@@ -45117,6 +45148,7 @@ export function viewGame(state: Game, id: string, now?: number) {
|
||||
? {
|
||||
route: g.pendingMobileMove.route,
|
||||
collect: g.pendingMobileMove.collect,
|
||||
collections: g.pendingMobileMove.collections,
|
||||
sandmasterPiles: g.pendingMobileMove.sandmasterPiles,
|
||||
}
|
||||
: null,
|
||||
|
||||
+1
-1
@@ -2115,7 +2115,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
steps: [
|
||||
'Ixians begin with three cyborgs and three suboids inside. After the first storm moves, select its pointing sector in any non-stronghold territory, including the Polar Sink. Placement collects no spice.',
|
||||
'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.',
|
||||
'The route preview shows spice available in its visited sectors, including the starting sector. Collection may be declined. A public Karama response resolves before any relocation or collection. Multiple-pile capacity and precise route interpretations remain under audit.',
|
||||
'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 Ixian force inside, separately in each sector. 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.',
|
||||
'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.',
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import { isValidElement, type ReactElement, type ReactNode } from 'react';
|
||||
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 type { MobileStronghold } from '../components/mobile-stronghold';
|
||||
|
||||
type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>;
|
||||
function elements(node: ReactNode): Element[] {
|
||||
if (Array.isArray(node)) return node.flatMap(elements);
|
||||
if (!isValidElement<{ children?: ReactNode; [key: string]: unknown }>(node)) return [];
|
||||
return [node, ...elements(node.props.children)];
|
||||
}
|
||||
|
||||
/** Controlled public projection; the hook harness operates the real component
|
||||
* callbacks without claiming browser hydration or server-side validation. */
|
||||
function fixture() {
|
||||
const g = createGame('HMSUI', newPlayer('i', 'Ixians', 'ixians'), false, ['ix']);
|
||||
g.players.push(newPlayer('a', 'Atreides', 'atreides'));
|
||||
g.status = 'playing'; g.phase = 5; g.turn = 2; g.storm = 18;
|
||||
g.active = 'i'; g.order = ['i', 'a']; g.movementRemaining = ['i', 'a'];
|
||||
g.mobileStronghold = { location: 'polar_sink:0' };
|
||||
g.players[0].forces = { [board.MOBILE_LOCATION]: 6 };
|
||||
g.players[0].reserves = 14;
|
||||
const next = board.GRAPH['polar_sink:0'].find(key =>
|
||||
board.splitLocation(key).sector !== g.storm &&
|
||||
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 };
|
||||
}
|
||||
|
||||
function controller(game: GameView, sandmasterPile?: string, card?: string) {
|
||||
const states: unknown[] = [], sent: Action[] = [];
|
||||
let cursor = 0;
|
||||
const exports: { MobileStronghold?: typeof MobileStronghold } = {};
|
||||
runInNewContext(ts.transpileModule(readFileSync(new URL('../components/mobile-stronghold.tsx', import.meta.url), 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX },
|
||||
}).outputText, {
|
||||
exports,
|
||||
require(name: string) {
|
||||
if (name === 'react/jsx-runtime') return jsxRuntime;
|
||||
if (name === 'react') return { useState(initial: unknown) {
|
||||
const index = cursor++;
|
||||
if (!(index in states)) states[index] = initial;
|
||||
return [states[index], (next: unknown) => { states[index] = next; }];
|
||||
} };
|
||||
if (name === '@/components/ui/button') return { Button: 'button' };
|
||||
if (name === '@/components/ui/input') return { Input: 'input' };
|
||||
if (name === './help-tip') return { HelpTip: () => null };
|
||||
if (name === '@/game/board') return board;
|
||||
if (name === '@/game/sandmaster-movement') return { quoteSandmasterStronghold: () => sandmasterPile
|
||||
? { piles: { [board.splitLocation(sandmasterPile).territory]: [sandmasterPile] } } : null };
|
||||
throw new Error(`Unexpected import ${name}`);
|
||||
},
|
||||
});
|
||||
assert.ok(exports.MobileStronghold);
|
||||
const render = () => { cursor = 0; return elements(exports.MobileStronghold!({ game, busy: false, card, act: action => sent.push(action) })); };
|
||||
const chooseNext = (value: string) => {
|
||||
const select = render().find(element => element.props['aria-label'] === 'Next route sector'); assert.ok(select);
|
||||
(select.props.onChange as (event: { target: { value: string } }) => void)({ target: { value } });
|
||||
};
|
||||
const inputs = () => render().filter(element => element.type === 'input' && element.props.type === 'number');
|
||||
const edit = (index: number, value: string) => {
|
||||
const input = inputs()[index]; assert.ok(input);
|
||||
(input.props.onChange as (event: { target: { value: string } }) => void)({ target: { value } });
|
||||
};
|
||||
const submit = () => {
|
||||
const button = render().find(element => element.type === 'button' &&
|
||||
(element.props.children === 'Declare stronghold route' || element.props.children === 'Spend Karama and relocate'));
|
||||
assert.ok(button); return button;
|
||||
};
|
||||
return { render, chooseNext, inputs, edit, submit, sent };
|
||||
}
|
||||
|
||||
void test('route controls default to per-sector maximum and submit partial choices', () => {
|
||||
const { view, next } = fixture(), ui = controller(view);
|
||||
ui.chooseNext(next);
|
||||
assert.deepEqual(ui.inputs().map(input => input.props.value), [3, 12]);
|
||||
ui.edit(0, '1'); ui.edit(1, '4');
|
||||
assert.deepEqual(ui.inputs().map(input => input.props.value), ['1', '4']);
|
||||
const button = ui.submit(); assert.equal(button.props.disabled, false);
|
||||
(button.props.onClick as () => void)();
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(ui.sent[0])), { type: 'decision', route: ['polar_sink:0', next],
|
||||
collect: true, collections: [{ location: 'polar_sink:0', amount: 1 }, { location: next, amount: 4 }] });
|
||||
// A forged selection cannot enable a route that repeats a sector.
|
||||
ui.chooseNext('polar_sink:0');
|
||||
assert.equal(ui.submit().props.disabled, true);
|
||||
});
|
||||
|
||||
void test('invalid collection values disable submission; skipping sends route-ordered zeros', () => {
|
||||
const { view, next } = fixture(), ui = controller(view, undefined, 'karama-card');
|
||||
ui.chooseNext(next);
|
||||
for (const value of ['', '-1', '13', '1.5']) {
|
||||
ui.edit(1, value);
|
||||
assert.equal(ui.inputs()[1].props['aria-invalid'], true);
|
||||
assert.equal(ui.submit().props.disabled, true);
|
||||
}
|
||||
const checkbox = ui.render().find(element => element.type === 'input' && element.props.type === 'checkbox');
|
||||
assert.ok(checkbox);
|
||||
(checkbox.props.onChange as (event: { target: { checked: boolean } }) => void)({ target: { checked: false } });
|
||||
const button = ui.submit(); assert.equal(button.props.disabled, false);
|
||||
(button.props.onClick as () => void)();
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(ui.sent[0])), { type: 'card', mode: 'special', card: 'karama-card',
|
||||
route: ['polar_sink:0', next], collect: false,
|
||||
collections: [{ location: 'polar_sink:0', amount: 0 }, { location: next, amount: 0 }] });
|
||||
});
|
||||
|
||||
void test('controlled Sandmaster pile is debited before the route input cap', () => {
|
||||
const { view, next } = fixture(); view.spice[next] = 3;
|
||||
const ui = controller(view, next); ui.chooseNext(next);
|
||||
assert.equal(ui.inputs()[1].props.max, 2); assert.equal(ui.inputs()[1].props.value, 2);
|
||||
const button = ui.submit(); assert.equal(button.props.disabled, false);
|
||||
(button.props.onClick as () => void)();
|
||||
const action = JSON.parse(JSON.stringify(ui.sent[0])) as Action;
|
||||
assert.deepEqual(action.sandmasterPiles, [next]);
|
||||
assert.deepEqual(action.collections, [{ location: 'polar_sink:0', amount: 3 }, { location: next, amount: 2 }]);
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { type Action, type Game } from '../game/engine';
|
||||
import { MOBILE_LOCATION, mobileRoutes, splitLocation, territory } from '../game/board';
|
||||
import { createSemutaIxianMovementFixture as fixture, actSemutaIxianMovement as act,
|
||||
allowSemutaIxianMovement as allow, passSemutaIxianMovement as pass,
|
||||
semutaIxianMovementPlayer as player, semutaIxianMovementInventory as inventory } from './fixture-semuta-ixian-movement';
|
||||
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before=structuredClone(game); assert.throws(() => act(game,actor,action)); assert.deepEqual(game,before);
|
||||
}
|
||||
function totalSpice(game: Game) { return Object.values(game.spice).reduce((sum,value) => sum+value,0); }
|
||||
function selections(route: readonly string[], chosen: Readonly<Record<string,number>> = {}) {
|
||||
return route.map(location => ({location,amount:chosen[location] ?? 0}));
|
||||
}
|
||||
|
||||
/** The source's actual turn-2 native route and forces are retained. For two
|
||||
* sector-choice tests only, redistribute existing board spice into two sand
|
||||
* sectors of one valid distinct-sector route. The total is conserved; the
|
||||
* resulting pile locations are a controlled setup, not earned Spice Blows. */
|
||||
function twoPiles(game: Game) {
|
||||
const staged=structuredClone(game), before=totalSpice(staged);
|
||||
assert.ok(before >= 5, 'The real preceding blow supplies at least five spice');
|
||||
const route=mobileRoutes(staged,3).find(candidate =>
|
||||
candidate.filter(key => territory(splitLocation(key).territory).type === 'sand').length >= 2);
|
||||
assert.ok(route, 'A native connected route must visit two sand sectors');
|
||||
const [first,second]=route.filter(key => territory(splitLocation(key).territory).type === 'sand');
|
||||
const original=Object.keys(staged.spice)[0]; assert.ok(original);
|
||||
staged.spice={ [first]:3, [second]:2 };
|
||||
staged.spice[original]=(staged.spice[original] ?? 0)+before-5;
|
||||
assert.equal(totalSpice(staged),before); assert.notEqual(first,second);
|
||||
return {game:staged,route,first,second};
|
||||
}
|
||||
|
||||
for (const advanced of [false,true]) {
|
||||
const mode=advanced ? 'Advanced' : 'Basic';
|
||||
void test(`${mode} the actual prior-blow route accepts a partial native collection and keeps omitted legacy behavior`, () => {
|
||||
const f=fixture({kind:'mobileStronghold',advanced});
|
||||
const source=structuredClone(f.beforeDeclaration), route=f.route!;
|
||||
const pile=f.collection[0].location, sourceSpice=structuredClone(source.spice);
|
||||
assert.ok((source.spice[pile] ?? 0)>0); assert.equal(player(source,f.owner).forces[MOBILE_LOCATION],6);
|
||||
const records=selections(route,{[pile]:1});
|
||||
let pending=act(source,f.owner,{type:'decision',route,collect:true,collections:records});
|
||||
assert.equal(pending.response?.kind,'mobileStronghold');
|
||||
assert.deepEqual(pending.pendingMobileMove?.route,route);
|
||||
assert.deepEqual(pending.spice,sourceSpice,'A pending cancelable move spends no board spice');
|
||||
let done=allow(pending);
|
||||
assert.equal(player(done,f.owner).spice,player(source,f.owner).spice+1);
|
||||
assert.equal(done.spice[pile] ?? 0,sourceSpice[pile]-1);
|
||||
for (const [key,amount] of Object.entries(sourceSpice)) if (key !== pile) assert.equal(done.spice[key] ?? 0,amount);
|
||||
assert.equal(done.mobileStronghold?.location,route.at(-1));
|
||||
assert.deepEqual(inventory(done),f.originalInventory);
|
||||
// Legacy `collect:true` keeps the historical complete per-sector harvest.
|
||||
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+f.collectionAmount);
|
||||
assert.deepEqual(inventory(done),f.originalInventory);
|
||||
// Legacy explicit false declines every native route collection.
|
||||
pending=act(source,f.owner,{type:'decision',route,collect:false}); done=allow(pending);
|
||||
assert.equal(player(done,f.owner).spice,player(source,f.owner).spice);
|
||||
assert.deepEqual(done.spice,sourceSpice);
|
||||
});
|
||||
|
||||
void test(`${mode} ordered route choices can take from one sector and leave another`, () => {
|
||||
const f=fixture({kind:'mobileStronghold',advanced}), staged=twoPiles(f.beforeDeclaration);
|
||||
const before=structuredClone(staged.game), records=selections(staged.route,{[staged.first]:2});
|
||||
let game=act(staged.game,f.owner,{type:'decision',route:staged.route,collect:true,collections:records});
|
||||
assert.equal(game.response?.kind,'mobileStronghold');
|
||||
assert.deepEqual(game.spice,before.spice);
|
||||
game=allow(game);
|
||||
assert.equal(player(game,f.owner).spice,player(before,f.owner).spice+2);
|
||||
assert.equal(game.spice[staged.first] ?? 0,before.spice[staged.first]-2);
|
||||
assert.equal(game.spice[staged.second],before.spice[staged.second]);
|
||||
assert.equal(totalSpice(game),totalSpice(before)-2);
|
||||
assert.deepEqual(inventory(game),f.originalInventory);
|
||||
});
|
||||
|
||||
void test(`${mode} malformed route allocations and repeated routes reject without mutation`, () => {
|
||||
const f=fixture({kind:'mobileStronghold',advanced}), staged=twoPiles(f.beforeDeclaration);
|
||||
const {game,route,first,second}=staged;
|
||||
const valid=selections(route,{[first]:1,[second]:1});
|
||||
const action=(collections:unknown,customRoute=route,collect=true): Action =>
|
||||
({type:'decision',route:customRoute,collect,collections});
|
||||
reject(game,f.owner,action(valid.slice(1)));
|
||||
reject(game,f.owner,action([...valid,{location:first,amount:0}]));
|
||||
const wrong=structuredClone(valid); wrong[0].location=first; if (route[0] === first) wrong[0].location=second;
|
||||
reject(game,f.owner,action(wrong));
|
||||
const neg=structuredClone(valid); neg[route.indexOf(first)].amount=-1; reject(game,f.owner,action(neg));
|
||||
const fractional=structuredClone(valid); fractional[route.indexOf(first)].amount=0.5; reject(game,f.owner,action(fractional));
|
||||
const oversized=structuredClone(valid); oversized[route.indexOf(first)].amount=game.spice[first]+1; reject(game,f.owner,action(oversized));
|
||||
reject(game,f.owner,action(valid,route,false));
|
||||
const repeated=[...route,route[route.length-2]];
|
||||
reject(game,f.owner,action(selections(repeated),repeated));
|
||||
assert.deepEqual(inventory(game),f.originalInventory);
|
||||
});
|
||||
|
||||
void test(`${mode} selection respects the per-force sector cap and the original Karama cancel`, () => {
|
||||
const f=fixture({kind:'mobileStronghold',advanced}), staged=twoPiles(f.beforeDeclaration);
|
||||
// Controlled force-custody supplement: move five original Ixian counters
|
||||
// from the HMS to reserves, retaining one of the original three Cyborgs.
|
||||
// Totals and the typed seven-Cyborg inventory remain unchanged.
|
||||
const reduced=structuredClone(staged.game), ix=player(reduced,f.owner);
|
||||
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(reduced.spice[staged.first] >= 3);
|
||||
const tooMuch=selections(staged.route,{[staged.first]:3});
|
||||
reject(reduced,f.owner,{type:'decision',route:staged.route,collect:true,collections:tooMuch});
|
||||
const records=selections(staged.route,{[staged.first]:2,[staged.second]:1});
|
||||
let game=act(reduced,f.owner,{type:'decision',route:staged.route,collect:true,collections:records});
|
||||
assert.equal(game.response?.kind,'mobileStronghold');
|
||||
assert.deepEqual(game.spice,reduced.spice); assert.equal(player(game,f.owner).spice,ix.spice);
|
||||
game=act(game,f.spender,f.action);
|
||||
// Fresh Basic CHOAM/Ix setup now enables printed Kull by default. Decline
|
||||
// its real intervening offer; the original Karama then reaches Semuta.
|
||||
if (game.pendingKull?.stage === 'offer') {
|
||||
const frame=game.pendingKull;
|
||||
game=act(game,frame.player,{type:'kullDecision',event:frame.event,decline:true});
|
||||
}
|
||||
assert.ok(game.pendingTreacheryDiscard?.reaction,'The actual Karama cost offers its native Semuta continuation');
|
||||
game=pass(game);
|
||||
assert.equal(game.pendingMobileMove ?? null,null);
|
||||
assert.equal(game.mobileStronghold?.location,reduced.mobileStronghold?.location);
|
||||
assert.deepEqual(game.spice,reduced.spice); assert.equal(player(game,f.owner).spice,ix.spice);
|
||||
assert.deepEqual(inventory(game),f.originalInventory);
|
||||
});
|
||||
}
|
||||
@@ -400,6 +400,24 @@ void test('special Karama relocates at most two territories without spending ord
|
||||
assert.equal(g.discard.at(-1)?.id, card.id);
|
||||
conserve(g);
|
||||
});
|
||||
void test('special Karama retains a chosen partial stronghold collection', () => {
|
||||
const g = fixture();
|
||||
g.advanced = true;
|
||||
const card = baseDeck().find((c) => c.effect === 'karama')!;
|
||||
g.players[0].hand = [card];
|
||||
const route = mobileRoutes(g, 2).find((r) => mobileRouteDistance(r) === 2)!;
|
||||
const pile = route.at(-1)!;
|
||||
g.spice[pile] = 5; // Controlled board-spice supplement for this special-use path.
|
||||
const collections = route.map(location => ({ location, amount: location === pile ? 2 : 0 }));
|
||||
const action = { type: 'card', mode: 'special', card: card.id, route, collect: true, collections };
|
||||
const done = applyAction(g, 'i', action);
|
||||
assert.equal(done.mobileStronghold!.location, pile);
|
||||
assert.equal(done.spice[pile], 3);
|
||||
assert.equal(done.players[0].spice, g.players[0].spice + 2);
|
||||
assert.equal(done.players[0].specialKaramaUsed, true);
|
||||
assert.equal(done.discard.at(-1)?.id, card.id);
|
||||
conserve(done);
|
||||
});
|
||||
void test('all AI levels select legal placement and relocation using private projections', () => {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let g = nextStorm();
|
||||
|
||||
@@ -52,7 +52,12 @@ function nativeSource(f: SemutaIxianMovementFixture) {
|
||||
assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
}
|
||||
function open(f: SemutaIxianMovementFixture) {
|
||||
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard; assert.ok(pending);
|
||||
let game = act(structuredClone(f.game), f.spender, f.action);
|
||||
if (game.pendingKull?.stage === 'offer') {
|
||||
const frame = game.pendingKull;
|
||||
game = act(game, frame.player, { type: 'kullDecision', event: frame.event, decline: true });
|
||||
}
|
||||
const pending = game.pendingTreacheryDiscard; assert.ok(pending);
|
||||
assert.equal(pending.continuation.kind, 'karamaMovementDiscard');
|
||||
if (pending.continuation.kind !== 'karamaMovementDiscard') throw Error('Expected the exact movement cancellation cost');
|
||||
assert.deepEqual(pending.continuation.use.response, f.game.response);
|
||||
|
||||
Reference in new issue
Block a user