import assert from 'node:assert/strict'; import test from 'node:test'; import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { PlanetologistMovement, planetologistMoveDraft } from '../components/planetologist-movement'; import { PlanetologistMovementAction, type PlanetologistMovementActionProps } from '../components/planetologist-movement-action'; import { planetologistFlightSelection, type PlanetologistFlight } from '../game/planetologist-flight'; import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine'; import { gameTerritories, location, splitLocation } from '../game/board'; import { groundMoveRouteDistance } from '../game/bot-mobility'; import { richeseCards } from '../game/richese-cards'; import { createRicheseNoField, deployRicheseNoField, projectRicheseNoField } from '../game/richese-no-field'; const near = 'wind_pass:14', other = 'wind_pass_north:17', middle = 'false_wall_west:16'; const target = 'polar_sink:0'; // Controlled owned projections test actual selection/submission callbacks, not // earned card/token custody or authoritative engine continuation. Independent // native physical/Discovery sources provide that proof. Stateful UI uses React. function projected(richese = false): GameView { const source = createGame('PLANETOLOGISTFLIGHTCONTROLS', newPlayer('p', 'Pilot', richese ? 'richese' : 'emperor'), false, ['choam']); joinGame(source, newPlayer('a', 'Atreides', 'atreides')); const game = viewGame(source, '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 = { [near]: 2, [other]: 2, [middle]: 2 }; me.moved = 0; me.movesAllowed = 1; me.shipped = true; game.leaderSkills = { offer: null, eligibleLeaders: [], battleChoice: null, assignments: [{ skill: 'planetologist', 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.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 }], } as NonNullable; game.discoveryOrnithopter = { token: 'discovery-token-1', acquiredTurn: 1, range: 3, blocked: null }; return game; } function active(game: GameView, mode: 'range3' | 'twoGroups' = 'twoGroups') { const flight = game.ornithopter!; flight.active = { event: 'controlled-active-flight', mode, completed: mode === 'twoGroups' ? 1 : 0, remaining: 1, cohort: { forces: { [near]: 1, [other]: 1, [middle]: 1 }, elites: {} } }; game.playedOrnithopter = { player: game.me, card: flight.card, mode, completed: flight.active.completed }; game.players[0].moved = mode === 'twoGroups' ? 1 : 0; return game; } function atDistance(game: GameView, count: number): string { const source = gameTerritories(game).flatMap(place => place.sectors.map(sector => location(place.id, sector))) .find(from => !['arrakeen', 'carthag', 'false_wall_west'].includes(splitLocation(from).territory) && groundMoveRouteDistance(game, game.players[0], from, target) === count); assert.ok(source, `A canonical controlled source at distance ${count} is available`); game.players[0].forces[source] = 2; return source; } function propsFor(game: GameView, flight: PlanetologistFlight = 'range3', forces: Record = { [middle]: 1 }): PlanetologistMovementActionProps { return { game, mode: 'range', destination: 'polar_sink', sector: 0, forces, elites: {}, flight, busy: false, act() {} }; } function click(props: PlanetologistMovementActionProps): Action[] { const actions: Action[] = []; PlanetologistMovementAction({ ...props, act(action) { actions.push(action); } }).props.onClick(); return actions; } function expected(props: PlanetologistMovementActionProps): Action { return { type: 'move', planetologist: props.mode, forces: props.forces, ...planetologistFlightSelection(props.game, props.flight).fields, territory: props.destination, sector: props.sector }; } function markup(game: GameView, busy = false) { return renderToStaticMarkup(createElement(PlanetologistMovement, { game, destination: 'polar_sink', sector: 0, busy, act() {} })); } function button(props: PlanetologistMovementActionProps) { return PlanetologistMovementAction(props); } void test('ordinary selection and six/seven-argument drafts retain the original physical action without flight fields', () => { const game = projected(), before = structuredClone(game); assert.deepEqual(planetologistFlightSelection(game), { fields: {}, blocked: null }); const ordinary = { type: 'move', planetologist: 'range', forces: { [middle]: 1 }, territory: 'polar_sink', sector: 0 }; assert.deepEqual(planetologistMoveDraft(game, 'range', 'polar_sink', 0, { [middle]: 1 }, {}).action, ordinary); assert.deepEqual(planetologistMoveDraft(game, 'range', 'polar_sink', 0, { [middle]: 1 }, {}, false).action, ordinary); assert.deepEqual(click(propsFor(game, 'ordinary')), [ordinary]); assert.deepEqual(game, before); }); void test('first physical selections reuse the offered card and exact printed mode with fixed three only for range3', () => { const game = projected(); assert.deepEqual(planetologistFlightSelection(game, 'range3'), { fields: { movementCard: game.ornithopter!.card.id, ornithopter: 'range3' }, rangeOverride: 3, blocked: null, }); assert.deepEqual(planetologistFlightSelection(game, 'twoGroups'), { fields: { movementCard: game.ornithopter!.card.id, ornithopter: 'twoGroups' }, blocked: null, }); }); void test('Discovery selection reuses the owned carried token and acquisition history without another flight source', () => { const game = carried(projected()); assert.deepEqual(planetologistFlightSelection(game, 'discovery'), { fields: { discoveryOrnithopter: 'discovery-token-1' }, rangeOverride: 3, blocked: null, }); const props = propsFor(game, 'discovery'); assert.deepEqual(click(props), [expected(props)]); assert.equal('movementCard' in click(props)[0], false); assert.equal('ornithopterEvent' in click(props)[0], false); }); void test('fixed physical and Discovery flight allow range or two-origin gather with each origin up to three', () => { for (const flight of ['range3', 'discovery'] as const) for (const mode of ['range', 'gather'] as const) { const game = flight === 'discovery' ? carried(projected()) : projected(); const far = atDistance(game, 3), before = structuredClone(game); const forces = mode === 'range' ? { [far]: 1 } : { [far]: 1, [middle]: 2 }; const props = { ...propsFor(game, flight, forces), mode }; assert.equal(planetologistMoveDraft(game, mode, 'polar_sink', 0, forces, {}, false, flight).blocked, null); assert.deepEqual(click(props), [expected(props)]); assert.equal(button({ ...props, busy: true }).props.disabled, true); assert.deepEqual(click({ ...props, busy: true }), []); assert.deepEqual(game, before); } }); void test('fixed three cannot become four for either Planetologist alternative or flight source', () => { for (const flight of ['range3', 'discovery'] as const) for (const mode of ['range', 'gather'] as const) { const game = flight === 'discovery' ? carried(projected()) : projected(); const distant = atDistance(game, 4); const forces = mode === 'range' ? { [distant]: 1 } : { [distant]: 1, [near]: 1 }; assert.deepEqual(click({ ...propsFor(game, flight, forces), mode }), []); } }); void test('two-group card flight keeps boosted range separate from gather normal range and uses one card movement per gather', () => { const game = projected(), far = atDistance(game, 3); assert.deepEqual(click(propsFor(game, 'twoGroups')), [expected(propsFor(game, 'twoGroups'))]); const gather = { ...propsFor(game, 'twoGroups', { [near]: 1, [other]: 1 }), mode: 'gather' as const }; assert.deepEqual(click(gather), [expected(gather)]); assert.deepEqual(click({ ...gather, forces: { [near]: 1, [middle]: 1 } }), []); assert.deepEqual(click(propsFor(game, 'twoGroups', { [far]: 1 })), []); assert.equal(gather.game.players[0].moved, 0, 'Quoting/submitting to a collector does not manufacture a card movement'); }); void test('the active own frame resolves ordinary to its exact event and permits remaining card movement after ordinary allowance is spent', () => { const game = active(projected()), before = structuredClone(game); const quote = planetologistFlightSelection(game); assert.deepEqual(quote, { fields: { ornithopterEvent: 'controlled-active-flight' }, cohort: game.ornithopter!.active!.cohort, blocked: null }); for (const flight of ['ordinary', 'twoGroups'] as const) { const props = { ...propsFor(game, flight, { [near]: 1, [other]: 1 }), mode: 'gather' as const }; assert.equal(button(props).props.disabled, false); assert.deepEqual(click(props), [expected(props)]); assert.equal('movementCard' in click(props)[0], false); } assert.deepEqual(click(propsFor(game, 'range3')), []); assert.deepEqual(game, before); assert.deepEqual(click(propsFor(carried(structuredClone(game)), 'discovery')), []); }); void test('active cohort validation includes every gathered source and exact original normal/elite quotas', () => { const game = active(projected()); const gather = { ...propsFor(game, 'twoGroups', { [near]: 2, [other]: 1 }), mode: 'gather' as const }; assert.deepEqual(click(gather), []); game.players[0].elites = { forces: { [near]: 1 }, reserves: 0, tanks: 0, revived: 0 }; const typed = { ...propsFor(game, 'twoGroups', { [near]: 1 }), elites: { [near]: 1 } }; assert.deepEqual(click(typed), []); assert.deepEqual(click({ ...typed, elites: { [near]: 0 } }), [ { type: 'move', planetologist: 'range', forces: { [near]: 1 }, eliteForces: { [near]: 0 }, ornithopterEvent: 'controlled-active-flight', territory: 'polar_sink', sector: 0 }, ]); game.ornithopter!.active!.cohort!.elites[near] = 1; assert.equal(click(typed).length, 1); }); void test('the selected concealed zero marker must be an original unmoved member of an active physical cohort', () => { const game = active(projected(true)); const tokens = createRicheseNoField(['flight-zero', 'flight-three', 'flight-five']); const model = deployRicheseNoField(tokens, { tokenId: 'flight-zero', controller: game.me, location: splitLocation(near) }); const publicMarker = projectRicheseNoField(model, false); game.players[0].noField = publicMarker; game.richeseNoField = { owner: game.me, event: 'controlled-marker-event', public: publicMarker, private: model } as NonNullable; const props = { ...propsFor(game, 'twoGroups', { [other]: 1 }), mode: 'gather' as const, includeNoField: true }; assert.deepEqual(click(props), []); game.ornithopter!.active!.cohort!.noField = { tokenId: 'flight-zero', event: 'controlled-marker-event', from: near }; const before = structuredClone(game); assert.deepEqual(click(props), [{ ...expected(props), noField: 'flight-zero', event: 'controlled-marker-event' }]); assert.deepEqual(game, before); game.ornithopter!.active!.cohort!.noField!.event = 'old-marker-event'; assert.deepEqual(click(props), []); }); void test('malformed completed stale foreign and missing-cohort active frames expose no flight selection', () => { const changes: ((game: GameView) => void)[] = [ game => { game.playedOrnithopter!.player = 'a'; }, game => { game.playedOrnithopter!.card = { ...game.ornithopter!.card, id: 'stale-card' }; }, game => { game.ornithopter!.active!.event = ''; }, game => { game.ornithopter!.active!.remaining = 0; }, game => { game.ornithopter!.active!.completed = 0.5; }, game => { game.ornithopter!.modes = []; }, game => { delete game.ornithopter!.active!.cohort; }, game => { game.ornithopter!.active!.cohort!.forces[near] = -1; }, game => { game.ornithopter!.active!.cohort!.elites[near] = 2; }, ]; for (const change of changes) { const game = active(projected()); change(game); assert.ok(planetologistFlightSelection(game).blocked); assert.deepEqual(click(propsFor(game, 'ordinary', { [near]: 1 })), []); } }); void test('first-card mode blocks and current carried-token ownership acquisition and removal remain authoritative', () => { const blocked = projected(); blocked.ornithopter!.modes[0].blocked = 'This card mode is unavailable.'; assert.equal(planetologistFlightSelection(blocked, 'range3').blocked, 'This card mode is unavailable.'); assert.deepEqual(click(propsFor(blocked)), []); blocked.ornithopter = null; assert.ok(planetologistFlightSelection(blocked, 'twoGroups').blocked); const changes: ((game: GameView) => void)[] = [ game => { game.discoveryOrnithopter = null; }, game => { game.discoveryOrnithopter!.blocked = 'Finish the current response.'; }, game => { game.discoveries!.tokens[0].owner = 'a'; }, game => { game.discoveries!.tokens[0].status = 'removed'; }, game => { game.discoveries!.tokens[0].acquiredTurn = 3; game.discoveryOrnithopter!.acquiredTurn = 3; }, game => { game.discoveries!.tokens[0].revealedTurn = 2; }, game => { game.discoveries!.tokens.push({ ...game.discoveries!.tokens[0], id: 'discovery-token-2' }); }, ]; for (const change of changes) { const game = carried(projected()); change(game); assert.ok(planetologistFlightSelection(game, 'discovery').blocked); assert.deepEqual(click(propsFor(game, 'discovery')), []); } }); void test('actual callbacks do not silently substitute a newly selected card event or Discovery token', () => { for (const flight of ['range3', 'twoGroups', 'discovery'] as const) { const game = flight === 'discovery' ? carried(projected()) : flight === 'twoGroups' ? active(projected()) : projected(); const actions: Action[] = []; const props = propsFor(game, flight, { [near]: 1 }); const leaf = PlanetologistMovementAction({ ...props, act(action) { actions.push(action); } }); assert.equal(leaf.props.disabled, false); if (flight === 'range3') game.ornithopter!.card = { ...game.ornithopter!.card, id: 'another-card' }; else if (flight === 'twoGroups') game.ornithopter!.active!.event = 'new-flight-event'; else { game.discoveries!.tokens[0].id = 'discovery-token-2'; game.discoveryOrnithopter!.token = 'discovery-token-2'; } leaf.props.onClick(); assert.deepEqual(actions, []); } }); void test('current phase response automatic player allowance and trainer guards still protect every actual flight callback', () => { const changes: ((game: GameView) => void)[] = [ game => { game.phase = 6; }, game => { game.active = 'a'; }, game => { game.response = {} 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; }, ]; for (const flight of ['range3', 'twoGroups', 'discovery'] as const) for (const change of changes) { const game = flight === 'discovery' ? carried(projected()) : projected(); change(game); const props = { ...propsFor(game, flight), unavailableReason: '' }; assert.deepEqual(click(props), []); assert.equal(button(props).props.disabled, true); } }); void test('the parent exposes all transport choices persistent provisional counting and original second-movement controls', () => { const game = carried(projected()); const html = markup(game); for (const value of ['ordinary', 'range3', 'twoGroups', 'discovery']) assert.match(html, new RegExp(`