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; 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(/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; }, game => { game.decision = { kind: 'mobileStronghold', player: 'a', placement: false }; }, game => { game.truthtrance = {} as NonNullable; }, 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(`