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
@@ -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