291 lines
18 KiB
TypeScript
291 lines
18 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { createElement } from 'react';
|
|
import { renderToStaticMarkup } from 'react-dom/server';
|
|
import { SandmasterMovement, sandmasterMoveDraft } from '../components/sandmaster-movement';
|
|
import { SandmasterMovementAction, type SandmasterMovementActionProps, type MovementFlight } from '../components/sandmaster-movement-action';
|
|
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
|
|
import { planetologistFlightSelection as movementFlightSelection } from '../game/planetologist-flight';
|
|
import { gameTerritories, location, splitLocation } from '../game/board';
|
|
import { groundMoveRouteDistance } from '../game/bot-mobility';
|
|
import { richeseCards } from '../game/richese-cards';
|
|
import { createRicheseNoField, deployRicheseNoField, projectRicheseNoField, type NoFieldValue } from '../game/richese-no-field';
|
|
import { sandmasterActionOrder, sandmasterDefaultChoice, sandmasterOrderSources, sandmasterRouteDistance } from '../game/sandmaster-movement';
|
|
|
|
const source = 'false_wall_west:16', otherSource = 'false_wall_west:17';
|
|
const destination = 'wind_pass:16', firstPile = 'wind_pass:15', secondPile = 'wind_pass:16';
|
|
const ordinary: Action = { type: 'move', forces: { [source]: 2 }, territory: 'wind_pass', sector: 16 };
|
|
|
|
// Controlled personalized capabilities supplement the independent native flight
|
|
// and Richese marker sources. No trainer/card/token/army/receipt is installed in
|
|
// an authoritative Game. React renders the stateful route/pile parent; only the
|
|
// hook-free actual submit leaf is entered directly.
|
|
function projected(richese = false): GameView {
|
|
const sourceGame = createGame('SANDMASTERFLIGHTCONTROLS', newPlayer('p', 'Pilot', richese ? 'richese' : 'emperor'), false, ['choam']);
|
|
joinGame(sourceGame, newPlayer('a', 'Atreides', 'atreides'));
|
|
const game = viewGame(sourceGame, 'p');
|
|
game.status = 'playing'; game.phase = 5; game.turn = 3; game.storm = 18; game.active = 'p';
|
|
game.order = ['p', 'a']; game.movementRemaining = ['p', 'a'];
|
|
const me = game.players[0];
|
|
me.forces = { [source]: 3, [otherSource]: 2 }; me.moved = 0; me.movesAllowed = 1; me.shipped = true;
|
|
game.spice = { [firstPile]: 4, [secondPile]: 3 };
|
|
game.leaderSkills = { offer: null, eligibleLeaders: [], battleChoice: null,
|
|
assignments: [{ skill: 'sandmaster', owner: me.id, controller: me.id, leader: me.leaders[0].id, captured: false, faceUp: true }] };
|
|
const card = richeseCards().find(candidate => candidate.effect === 'ornithopter'); assert.ok(card);
|
|
game.ornithopter = { card, blocked: null, active: null,
|
|
modes: [{ mode: 'range3', blocked: null }, { mode: 'twoGroups', blocked: null }] };
|
|
return game;
|
|
}
|
|
function carried(game: GameView) {
|
|
game.discoveryEnabled = true;
|
|
game.discoveries = { blocked: null, canInspect: [], canReveal: [], supplyCount: 0, newlyRevealed: [],
|
|
tokens: [{ id: 'discovery-token-1', type: 'smuggler', face: 'ornithopter', status: 'carried',
|
|
territory: 'funeral_plain', sector: 2, revealedTurn: 1, owner: game.me, acquiredTurn: 1 }] };
|
|
game.discoveryOrnithopter = { token: 'discovery-token-1', acquiredTurn: 1, range: 3, blocked: null };
|
|
return game;
|
|
}
|
|
function active(game: GameView) {
|
|
const card = game.ornithopter!;
|
|
card.active = { event: 'controlled-sandmaster-flight', mode: 'twoGroups', completed: 1, remaining: 1,
|
|
cohort: { forces: { [otherSource]: 1 }, elites: {} } };
|
|
game.playedOrnithopter = { player: game.me, card: card.card, mode: 'twoGroups', completed: 1 };
|
|
game.players[0].moved = 1;
|
|
return game;
|
|
}
|
|
function marker(game: GameView, value: NoFieldValue) {
|
|
const tokens = createRicheseNoField(['sand-zero', 'sand-three', 'sand-five']);
|
|
const model = deployRicheseNoField(tokens, { tokenId: tokens.tokens.find(token => token.value === value)!.id,
|
|
controller: game.me, location: splitLocation(source) });
|
|
const publicMarker = projectRicheseNoField(model, false);
|
|
game.players[0].noField = publicMarker;
|
|
game.richeseNoField = { owner: game.me, event: 'controlled-sandmaster-marker', public: publicMarker, private: model } as NonNullable<GameView['richeseNoField']>;
|
|
return game;
|
|
}
|
|
function markerMove(game: GameView, physical = false): Action {
|
|
return { type: 'move', forces: physical ? { [source]: 2 } : {},
|
|
noField: game.richeseNoField!.private!.deployed!.tokenId, event: game.richeseNoField!.event,
|
|
territory: 'wind_pass', sector: 16 };
|
|
}
|
|
function propsFor(game: GameView, flight?: MovementFlight, move: Action = ordinary): SandmasterMovementActionProps {
|
|
return { game, move, flight, busy: false, reasonId: 'controlled-sandmaster-reason', act() {} };
|
|
}
|
|
function click(props: SandmasterMovementActionProps): Action[] {
|
|
const actions: Action[] = [];
|
|
SandmasterMovementAction({ ...props, act(action) { actions.push(action); } }).props.onClick();
|
|
return actions;
|
|
}
|
|
function markup(game: GameView, move: Action = ordinary, busy = false) {
|
|
return renderToStaticMarkup(createElement(SandmasterMovement, { game, move, busy, act() {} }));
|
|
}
|
|
function targetAtDistance(game: GameView, distance: number) {
|
|
const found = gameTerritories(game).flatMap(place => place.sectors.map(sector => location(place.id, sector)))
|
|
.find(to => groundMoveRouteDistance(game, game.players[0], source, to) === distance);
|
|
assert.ok(found, `A controlled canonical target at distance ${distance} exists`);
|
|
return splitLocation(found);
|
|
}
|
|
|
|
void test('existing four-argument drafts and actual ordinary callbacks preserve the exact move routes and pile choice', () => {
|
|
const game = projected(), before = structuredClone(game);
|
|
const draft = sandmasterMoveDraft(game, ordinary);
|
|
assert.equal(draft.blocked, null);
|
|
assert.deepEqual(draft.choice?.collect, [firstPile]);
|
|
assert.deepEqual(click(propsFor(game)), [draft.action]);
|
|
assert.deepEqual(draft.action, { ...ordinary, sandmaster: draft.choice });
|
|
assert.deepEqual(game, before);
|
|
});
|
|
|
|
void test('actual range3 two-group and Discovery callbacks carry only the offered flight source and original Sandmaster choice', () => {
|
|
for (const flight of ['range3', 'twoGroups', 'discovery'] as const) {
|
|
const game = flight === 'discovery' ? carried(projected()) : projected(), before = structuredClone(game);
|
|
const draft = sandmasterMoveDraft(game, ordinary, undefined, [], flight);
|
|
assert.equal(draft.blocked, null);
|
|
assert.deepEqual(click(propsFor(game, flight)), [{ ...ordinary,
|
|
...movementFlightSelection(game, flight).fields, sandmaster: draft.choice }]);
|
|
assert.deepEqual(click({ ...propsFor(game, flight), busy: true }), []);
|
|
assert.deepEqual(game, before);
|
|
}
|
|
});
|
|
|
|
void test('fixed three-territory card and Discovery routes retain the cap while ordinary and two-group flight keep normal range', () => {
|
|
const game = projected(), far = targetAtDistance(game, 3), beyond = targetAtDistance(game, 4);
|
|
const farMove = { ...ordinary, ...far };
|
|
for (const flight of ['ordinary', 'twoGroups'] as const) assert.deepEqual(click(propsFor(game, flight, farMove)), []);
|
|
for (const flight of ['range3', 'discovery'] as const) {
|
|
const view = flight === 'discovery' ? carried(projected()) : projected();
|
|
assert.equal(click(propsFor(view, flight, farMove)).length, 1);
|
|
assert.deepEqual(click(propsFor(view, flight, { ...ordinary, ...beyond })), []);
|
|
}
|
|
});
|
|
|
|
void test('the declared connected non-shortest route is submitted unchanged rather than replaced by a shortest route', () => {
|
|
const game = projected(), routes = { [source]: [source, 'wind_pass:17', 'polar_sink:0', destination] };
|
|
const defaults = sandmasterDefaultChoice(game, game.me, { ...ordinary, ...movementFlightSelection(game, 'range3').fields });
|
|
assert.ok(defaults); assert.notDeepEqual(defaults.routes, routes);
|
|
assert.equal(sandmasterRouteDistance(game, routes[source]), 3);
|
|
const actions = click({ ...propsFor(game, 'range3'), routes });
|
|
assert.deepEqual(actions, [{ ...ordinary, ...movementFlightSelection(game, 'range3').fields,
|
|
sandmaster: { routes, collect: [firstPile] } }]);
|
|
assert.deepEqual(click({ ...propsFor(game, 'range3'), routes: { [source]: [source, 'arrakeen:10', destination] } }), []);
|
|
});
|
|
|
|
void test('actual pile choices select one positive sector per entered territory or explicitly decline all peers', () => {
|
|
const game = projected(), routes = { [source]: [source, 'wind_pass:17', destination] };
|
|
for (const [declined, collect] of [[[], [firstPile]], [[firstPile], [secondPile]], [[firstPile, secondPile], []]] as const) {
|
|
assert.deepEqual(click({ ...propsFor(game, 'range3'), routes, declined }), [
|
|
{ ...ordinary, ...movementFlightSelection(game, 'range3').fields, sandmaster: { routes, collect: [...collect] } },
|
|
]);
|
|
}
|
|
const html = markup(game);
|
|
assert.match(html, /Collect 1 spice in Wind Pass, sector 15/);
|
|
assert.match(html, /Collect 1 spice in Wind Pass, sector 16/);
|
|
});
|
|
|
|
void test('active second groups use their current event and remaining original cohort after the ordinary allowance is spent', () => {
|
|
const game = active(projected()), move = { ...ordinary, forces: { [otherSource]: 1 } };
|
|
const draft = sandmasterMoveDraft(game, move);
|
|
assert.equal(draft.blocked, null);
|
|
assert.deepEqual(click(propsFor(game, undefined, move)), [draft.action]);
|
|
assert.equal(draft.action?.ornithopterEvent, 'controlled-sandmaster-flight');
|
|
assert.equal(draft.action?.movementCard, undefined);
|
|
assert.deepEqual(click(propsFor(game, undefined, ordinary)), []);
|
|
assert.deepEqual(click(propsFor(game, 'discovery', move)), []);
|
|
const html = markup(game, move);
|
|
assert.match(html, /1 card movements completed · 1 remaining/);
|
|
});
|
|
|
|
void test('second-group quotas distinguish current physical normal and elite counters from the unmoved cohort', () => {
|
|
const game = active(projected());
|
|
game.players[0].elites = { forces: { [otherSource]: 1 }, reserves: 0, tanks: 0, revived: 0 };
|
|
const move = { ...ordinary, forces: { [otherSource]: 1 }, eliteForces: { [otherSource]: 1 } };
|
|
assert.deepEqual(click(propsFor(game, undefined, move)), []);
|
|
assert.equal(click(propsFor(game, undefined, { ...move, eliteForces: { [otherSource]: 0 } })).length, 1);
|
|
assert.deepEqual(click(propsFor(game, undefined, { ...move, forces: { [otherSource]: 2 } })), []);
|
|
});
|
|
|
|
void test('marker-only zero three and five use one moving source without reading or materializing the denomination', () => {
|
|
for (const value of [0, 3, 5] as const) for (const flight of ['ordinary', 'range3', 'twoGroups', 'discovery'] as const) {
|
|
const game = marker(flight === 'discovery' ? carried(projected(true)) : projected(true), value);
|
|
const move = markerMove(game), before = structuredClone(game);
|
|
const draft = sandmasterMoveDraft(game, move, undefined, [], flight);
|
|
assert.equal(draft.blocked, null);
|
|
assert.deepEqual(Object.keys(draft.choice!.routes), [source]);
|
|
assert.deepEqual(draft.choice!.collect, [firstPile]);
|
|
assert.deepEqual(click(propsFor(game, flight, move)), [draft.action]);
|
|
assert.deepEqual(game, before);
|
|
const html = markup(game, move);
|
|
assert.match(html, /aria-label="Provisional Sandmaster concealed marker"/);
|
|
assert.match(html, /including zero/);
|
|
assert.doesNotMatch(html, /sand-zero|sand-three|sand-five/);
|
|
}
|
|
});
|
|
|
|
void test('a same-sector marker and physical group share one route and collect once for the entered territory', () => {
|
|
const game = marker(projected(true), 5), move = markerMove(game, true);
|
|
const draft = sandmasterMoveDraft(game, move, undefined, [], 'range3');
|
|
const order = sandmasterActionOrder(game, game.me, draft.action!);
|
|
assert.ok(order);
|
|
assert.deepEqual(sandmasterOrderSources(order), [source]);
|
|
assert.deepEqual(Object.keys(draft.choice!.routes), [source]);
|
|
assert.deepEqual(draft.choice!.collect, [firstPile]);
|
|
assert.deepEqual(click(propsFor(game, 'range3', move)), [draft.action]);
|
|
assert.equal((markup(game, move).match(/<legend>False Wall West, sector 16 route<\/legend>/g) ?? []).length, 1);
|
|
const separate = { ...markerMove(game), forces: { [otherSource]: 1 } };
|
|
const twoRoutes = sandmasterMoveDraft(game, separate, undefined, [], 'range3');
|
|
assert.equal(Object.keys(twoRoutes.choice!.routes).length, 2);
|
|
assert.deepEqual(twoRoutes.choice!.collect, [firstPile]);
|
|
assert.deepEqual(click(propsFor(game, 'range3', separate)), [twoRoutes.action]);
|
|
});
|
|
|
|
void test('a marker in the active frame must remain an original unmoved cohort member', () => {
|
|
const game = active(marker(projected(true), 0)), move = markerMove(game);
|
|
assert.deepEqual(click(propsFor(game, undefined, move)), []);
|
|
game.ornithopter!.active!.cohort!.noField = { tokenId: 'sand-zero', event: game.richeseNoField!.event, from: source };
|
|
assert.equal(click(propsFor(game, undefined, move)).length, 1);
|
|
game.ornithopter!.active!.cohort!.noField!.event = 'old-marker-event';
|
|
assert.deepEqual(click(propsFor(game, undefined, move)), []);
|
|
});
|
|
|
|
void test('stale card token active event selected source trainer or selected pile invalidates an already-rendered callback', () => {
|
|
const checks: { flight: MovementFlight; change: (game: GameView) => void }[] = [
|
|
{ flight: 'range3', change: game => { game.ornithopter!.card = { ...game.ornithopter!.card, id: 'different-card' }; } },
|
|
{ flight: 'discovery', change: game => { game.discoveries!.tokens[0].id = 'discovery-token-2'; game.discoveryOrnithopter!.token = 'discovery-token-2'; } },
|
|
{ flight: 'range3', change: game => { game.players[0].forces[source] = 0; } },
|
|
{ flight: 'range3', change: game => { game.leaderSkills!.assignments[0].leader = game.players[0].leaders[1].id; } },
|
|
{ flight: 'range3', change: game => { game.spice[firstPile] = 2; } },
|
|
{ flight: 'range3', change: game => { game.spice[firstPile] = 0; } },
|
|
];
|
|
for (const { flight, change } of checks) {
|
|
const game = flight === 'discovery' ? carried(projected()) : projected(), actions: Action[] = [];
|
|
const leaf = SandmasterMovementAction({ ...propsFor(game, flight), act(action) { actions.push(action); } });
|
|
assert.equal(leaf.props.disabled, false);
|
|
change(game); leaf.props.onClick(); assert.deepEqual(actions, []);
|
|
}
|
|
const game = active(projected()), actions: Action[] = [];
|
|
const leaf = SandmasterMovementAction({ ...propsFor(game, undefined, { ...ordinary, forces: { [otherSource]: 1 } }), act(action) { actions.push(action); } });
|
|
game.ornithopter!.active!.event = 'changed-flight-event'; leaf.props.onClick(); assert.deepEqual(actions, []);
|
|
});
|
|
|
|
void test('foreign marker ownership current marker event and current token custody guard actual callbacks', () => {
|
|
for (const change of [
|
|
(game: GameView) => { game.richeseNoField!.owner = 'a'; },
|
|
(game: GameView) => { game.richeseNoField!.event = 'new-marker-event'; },
|
|
(game: GameView) => { game.richeseNoField!.private!.deployed = null; },
|
|
(game: GameView) => { game.richeseNoField!.private!.deployed!.controller = 'a'; },
|
|
]) {
|
|
const game = marker(projected(true), 0), move = markerMove(game); change(game);
|
|
assert.deepEqual(click(propsFor(game, 'range3', move)), []);
|
|
}
|
|
const game = marker(projected(true), 0), actions: Action[] = [];
|
|
const leaf = SandmasterMovementAction({ ...propsFor(game, 'range3', markerMove(game)),
|
|
act(action) { actions.push(action); } });
|
|
assert.equal(leaf.props.disabled, false);
|
|
game.richeseNoField!.event = 'changed-marker-event';
|
|
leaf.props.onClick(); assert.deepEqual(actions, []);
|
|
});
|
|
|
|
void test('empty optional notices cannot bypass current ownership phase response automatic trainer or allowance guards', () => {
|
|
const changes: ((game: GameView) => void)[] = [
|
|
game => { game.phase = 6; }, game => { game.active = 'a'; },
|
|
game => { game.response = {} as NonNullable<GameView['response']>; },
|
|
game => { game.decision = { kind: 'mobileStronghold', player: 'a', placement: false }; },
|
|
game => { game.truthtrance = {} as NonNullable<GameView['truthtrance']>; },
|
|
game => { game.automaticContinuationPending = true; }, game => { game.players[0].autopilot = 'Easy'; },
|
|
game => { game.players[0].moved = 1; }, game => { game.players[0].leaders[0].dead = true; },
|
|
game => { game.me = 'a'; },
|
|
];
|
|
for (const change of changes) {
|
|
const game = projected(); change(game);
|
|
assert.deepEqual(click({ ...propsFor(game, 'range3'), unavailableReason: '' }), []);
|
|
}
|
|
const game = projected();
|
|
assert.deepEqual(click({ ...propsFor(game, 'range3'), busy: true }), []);
|
|
assert.deepEqual(click({ ...propsFor(game, 'range3'), unavailableReason: 'Finish the promised shipment first.' }), []);
|
|
assert.deepEqual(click(propsFor(game, 'range3', { ...ordinary, planetologist: 'range' })), []);
|
|
});
|
|
|
|
void test('the form retains all transport choices when the ordinary route is too long and explains provisional exact-route collection', () => {
|
|
const game = carried(projected()), far = { ...ordinary, ...targetAtDistance(game, 3) };
|
|
const html = markup(game, far);
|
|
assert.match(html, /Sandmaster movement/);
|
|
assert.match(html, /Movement transport/);
|
|
for (const choice of ['ordinary', 'range3', 'twoGroups', 'discovery']) assert.match(html, new RegExp(`<option value="${choice}"`));
|
|
assert.match(html, /aria-label="Provisional Sandmaster flight composition"/);
|
|
assert.match(html, /fixed-range card and Discovery flight allow routes up to three territories/);
|
|
assert.match(html, /Each two-group card movement keeps normal range and uses different original forces/);
|
|
assert.match(html, /Only one flight source may be used; Planetologist remains separate/);
|
|
assert.match(markup(projected()), /Provisional multi-pile policy/);
|
|
});
|
|
|
|
void test('marker routes and actual callbacks never read hidden denominations raw marker events or rival private inventories', () => {
|
|
const game = marker(projected(true), 0), move = markerMove(game);
|
|
for (const token of game.richeseNoField!.private!.tokens)
|
|
Object.defineProperty(token, 'value', { get() { throw Error('Sandmaster controls read hidden denomination'); } });
|
|
for (const player of game.players) for (const field of ['hand', 'traitors', 'faceDancers', 'spice', 'reserves', 'noFieldEvent'])
|
|
Object.defineProperty(player, field, { get() { throw Error(`Sandmaster controls read unrelated ${player.id}.${field}`); } });
|
|
assert.equal(click(propsFor(game, 'range3', move)).length, 1);
|
|
assert.match(markup(game, move), /Provisional Sandmaster concealed marker/);
|
|
const rival = marker(projected(true), 3), rivalMove = markerMove(rival); rival.richeseNoField!.owner = 'a';
|
|
Object.defineProperty(rival.richeseNoField!, 'private', { get() { throw Error('Sandmaster controls read foreign marker identity'); } });
|
|
assert.deepEqual(click(propsFor(rival, 'range3', rivalMove)), []);
|
|
});
|