Compose Planetologist movement with Ornithopter flights

This commit is contained in:
Quintine committed 2026-10-26 09:53:21 +11:00
1 parent 90c50736f2
commit 4af90e547c
24 files changed
+1607 -50

No files matched your search

+37
View File
@@ -29,6 +29,43 @@ function fixture() {
return { context, token, move };
}
const reload = <T>(value: T): T => JSON.parse(JSON.stringify(value));
void test('ordinary Discovery receipt signatures retain their legacy bytes with no Planetologist suffix', () => {
const { context, token, move } = fixture();
assert.equal(quoteDiscoveryFlight(context, token, move).selection,
'["p","false_wall_west",[["false_wall_west:16",2,1],["false_wall_west:17",1,0]],1,3,"the_great_flat",15,false,false,null,null]');
});
void test('Discovery Planetologist receipts bind both normalized origins and the exact skill choice', () => {
const { context, token, move } = fixture();
const gather: DiscoveryFlightMovement = { ...move,
origins: ['false_wall_west', 'wind_pass'], planetologist: { leader: 'p-leader', mode: 'gather' },
group: [['false_wall_west:16', 2], ['wind_pass:14', 1]],
eliteGroup: { 'false_wall_west:16': 1, 'wind_pass:14': 0 } };
const receipt = quoteDiscoveryFlight(context, token, gather);
assert.doesNotThrow(() => validateDiscoveryFlight(context, receipt, gather));
for (const changed of [
{ ...gather, planetologist: { leader: 'other-leader', mode: 'gather' as const } },
{ ...gather, origins: ['false_wall_west', 'sihaya_ridge'] },
{ ...gather, planetologist: { leader: 'p-leader', mode: 'range' as const } },
]) assert.throws(() => validateDiscoveryFlight(context, receipt, changed), DiscoveryFlightError);
const range = { ...move, planetologist: { leader: 'p-leader', mode: 'range' as const } };
assert.notEqual(quoteDiscoveryFlight(context, token, range).selection,
quoteDiscoveryFlight(context, token, move).selection);
});
void test('Discovery gathering rejects unused duplicate third or untrained origins before spending the token', () => {
const { context, token, move } = fixture(), before = structuredClone(context);
const gather: DiscoveryFlightMovement = { ...move,
origins: ['false_wall_west', 'wind_pass'], planetologist: { leader: 'p-leader', mode: 'gather' } };
for (const changed of [gather, { ...gather, origins: ['false_wall_west', 'false_wall_west'] },
{ ...gather, origins: ['wind_pass', 'false_wall_west'] },
{ ...gather, origins: ['false_wall_west', 'wind_pass', 'sihaya_ridge'] },
{ ...gather, planetologist: undefined }, { ...gather, origins: undefined }])
assert.throws(() => quoteDiscoveryFlight(context, token, changed), DiscoveryFlightError);
assert.deepEqual(context, before);
});
function rejectedUnchanged<T>(input: T, work: () => unknown) {
const before = structuredClone(input);
assert.throws(work, DiscoveryFlightError);
@@ -0,0 +1,162 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { gameTerritories, location, splitLocation } from '../game/board';
import { botEntryAllowed, groundMoveRouteDistance } from '../game/bot-mobility';
import { presenceAt } from '../game/force-presence';
import { planetologistLeader } from '../game/planetologist-movement';
import { validateDiscoveryState } from '../game/discoveries';
import { skillOfferRolls } from './fixture-independent-card-modules';
import { assertSelectedDeckSkillsInventory } from './fixture-selected-deck-skills';
import {
advanceClassicDiscoverySkills, classicDiscoverySkillsClean, createClassicDiscoverySkillsFixture,
revealClassicDiscoverySkills,
} from './fixture-discovery-classic-skills';
export type PlanetologistDiscoveryFlightStep = { actor: string; action: Action };
export const planetologistDiscoveryFlightClean = classicDiscoverySkillsClean;
export function planetologistDiscoveryFlightPlayer(game: Game, actor = 'guild') {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
}
export function planetologistDiscoveryFlightInventory(game: Game) {
assertSelectedDeckSkillsInventory(game); validateDiscoveryState(game.discoveries!);
return {
treachery: [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])]
.map(card => card.id).sort(),
skills: [...game.leaderSkills!.deck, ...game.leaderSkills!.assignments.map(assignment => assignment.skill),
...Object.values(game.leaderSkills!.offers).flatMap(offer => offer.cards)].sort(),
traitors: [...(game.traitorReserve ?? []), ...game.players.flatMap(player => player.traitors)].sort(),
discoveries: game.discoveries!.tokens.map(token => ({ id: token.id, face: token.face, type: token.type }))
.sort((a, b) => a.id.localeCompare(b.id)),
spice: [...game.spiceDeck, ...game.spiceDiscard.flat()].map(card => JSON.stringify(card)).sort(),
};
}
export function planetologistDiscoveryFlightPhysical(game: Game) {
return game.players.map(player => ({ id: player.id, spice: player.spice, reserves: player.reserves,
tanks: player.tanks, forces: structuredClone(player.forces), elites: structuredClone(player.elites),
leaders: structuredClone(player.leaders), hand: player.hand.map(card => card.id), moved: player.moved,
shipped: player.shipped }));
}
export function actPlanetologistDiscoveryFlight(game: Game, actor: string, action: Action,
actions: PlanetologistDiscoveryFlightStep[] = []): Game {
const next = applyAction(game, actor, action);
actions.push({ actor, action: structuredClone(action) }); return next;
}
export function advancePlanetologistDiscoveryFlight(game: Game, until: (state: Game) => boolean,
actions: PlanetologistDiscoveryFlightStep[] = []): Game {
return advanceClassicDiscoverySkills(game, until, actions);
}
/** Original unopened skill-offer lottery only; all other randomness stays native. */
function originalOffers<T>(game: Game, run: () => T): T {
const rolls = skillOfferRolls(game, { guild: 'planetologist', emperor: 'killer-medic', harkonnen: 'prana-bindu-adept' });
const original = crypto.getRandomValues.bind(crypto);
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1 && cursor < rolls.length) array[0] = rolls[cursor++];
else Reflect.apply(original, crypto, [array]);
return array;
};
try { return run(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
function routes(game: Game, actor: string, first: string) {
const view = viewGame(game, actor), me = view.players.find(player => player.id === actor)!;
const rocks = gameTerritories(game).filter(place => place.type === 'rock' &&
!game.players.some(player => presenceAt(player, place.id)))
.flatMap(place => place.sectors.map(sector => location(place.id, sector)))
.filter(key => splitLocation(key).sector !== game.storm);
const targets = rocks.filter(target => groundMoveRouteDistance(view, me, first, target) === 3 &&
botEntryAllowed(view, me, splitLocation(target).territory, splitLocation(target).sector, 'move', splitLocation(first).territory));
const rangeFourLocation = rocks.find(target => groundMoveRouteDistance(view, me, first, target) === 4 &&
botEntryAllowed(view, me, splitLocation(target).territory, splitLocation(target).sector, 'move', splitLocation(first).territory));
if (!targets.length || !rangeFourLocation) return null;
for (const targetLocation of targets) for (const second of rocks) {
if (splitLocation(second).territory === splitLocation(first).territory ||
splitLocation(second).territory === splitLocation(targetLocation).territory) continue;
if (groundMoveRouteDistance(view, me, second, targetLocation) !== 3) continue;
const gatherTooFarLocation = rocks.find(target => target !== second &&
splitLocation(target).territory !== splitLocation(second).territory &&
[first, second].every(from => groundMoveRouteDistance(view, me, from, target) <= 4) &&
[first, second].some(from => groundMoveRouteDistance(view, me, from, target) === 4));
if (gatherTooFarLocation) return { second, targetLocation, rangeFourLocation, gatherTooFarLocation };
}
return null;
}
/** Public Basic/Advanced G/E/H module, readiness and start controls precede the
* actual all14 offers/training. Original Collection earns the physical token;
* native next-turn chronology and a second funded shipment earn both sources.
* Only unopened lotteries/unplayed Spice order are selected. No played force,
* wallet, phase, card, token or receipt is installed. Advanced is preview proof. */
export function createPlanetologistDiscoveryFlightFixture(options: { advanced?: boolean } = {}) {
const advanced = options.advanced ?? false, actor = 'guild', observer = 'emperor', opponent = 'harkonnen';
let game = createGame('PLANETOLOGISTDISCOVERYFLIGHT', newPlayer(actor, 'Guild', 'guild'), advanced);
joinGame(game, newPlayer(observer, 'Emperor', 'emperor'));
joinGame(game, newPlayer(opponent, 'Harkonnen', 'harkonnen'));
const actions: PlanetologistDiscoveryFlightStep[] = [], initial = structuredClone(game);
const act = (owner: string, action: Action) => { game = actPlanetologistDiscoveryFlight(game, owner, action, actions); };
act(actor, { type: 'moduleOption', module: 'leaderSkills', enabled: true });
act(actor, { type: 'moduleOption', module: 'discoveries', enabled: true });
for (const player of game.players) act(player.id, { type: 'ready' });
const ready = structuredClone(game);
originalOffers(game, () => act(actor, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' }));
const started = structuredClone(game);
assert.equal(game.status, 'setup'); assert.ok(game.leaderSkills && game.discoveries);
const native = createClassicDiscoverySkillsFixture({ initial: game, skill: 'planetologist', face: 'ornithopter', amount: 3 });
actions.push(...native.actions.map(step => structuredClone(step)));
// Keep the actual completed chronology. Replaying setup would deal fresh
// Traitors when the last technique is chosen, rather than retain this deal.
assert.equal(native.game.status, 'playing');
game = native.game; const collection = structuredClone(game);
game = revealClassicDiscoverySkills(native, actions);
const acquired = structuredClone(game), originalToken = structuredClone(game.discoveries!.tokens.find(token => token.id === native.token)!);
assert.equal(originalToken.status, 'carried'); assert.equal(originalToken.owner, actor); assert.equal(originalToken.acquiredTurn, 1);
const reach = (until: (state: Game) => boolean) => { game = advancePlanetologistDiscoveryFlight(game, until, actions); };
reach(state => state.turn === 2 && state.phase === 5 && state.active === actor && planetologistDiscoveryFlightClean(state));
const first = location(native.parent, native.parentSector), selected = routes(game, actor, first);
assert.ok(selected, 'The real later board must offer two sources with one common fixed-three route and a four-route negative');
const beforeShipment = structuredClone(game), shipmentStep: PlanetologistDiscoveryFlightStep = { actor,
action: { type: 'ship', ...splitLocation(selected.second), amount: 1, elite: 0, allyPayment: 0 } };
act(actor, shipmentStep.action); reach(planetologistDiscoveryFlightClean);
const afterShipment = structuredClone(game);
assert.equal(planetologistDiscoveryFlightPlayer(beforeShipment, actor).spice - planetologistDiscoveryFlightPlayer(game, actor).spice, 1);
assert.equal(planetologistDiscoveryFlightPlayer(beforeShipment, actor).reserves - planetologistDiscoveryFlightPlayer(game, actor).reserves, 1);
assert.equal(planetologistDiscoveryFlightPlayer(game, actor).forces[first], 3);
assert.equal(planetologistDiscoveryFlightPlayer(game, actor).forces[selected.second], 1);
const trainer = planetologistLeader(game, actor); assert.ok(trainer);
const sources = [first, selected.second] as const, forces = { [first]: 3, [selected.second]: 1 };
const target = splitLocation(selected.targetLocation), rangeFour = splitLocation(selected.rangeFourLocation);
const gatherTooFar = splitLocation(selected.gatherTooFarLocation);
const action: Action = { type: 'move', planetologist: 'gather', forces,
discoveryOrnithopter: native.token, ...target };
const rangeAction: Action = { type: 'move', planetologist: 'range', forces: { [first]: 1 },
discoveryOrnithopter: native.token, ...target };
const rangeFourAction: Action = { ...rangeAction, ...rangeFour };
const gatherTooFarAction: Action = { ...action, ...gatherTooFar };
const originalInventory = planetologistDiscoveryFlightInventory(game);
return { game, rawGame: structuredClone(game), sourceGame: structuredClone(game), advanced, actor, owner: actor,
observer, opponent, trainer, leader: trainer, token: native.token, originalToken, acquiredTurn: originalToken.acquiredTurn!,
initial, ready, started, offered: native.setup, collection, acquired, native,
beforeShipment, shipmentStep, afterShipment, first, second: selected.second, source: first, sources, forces,
target, targetLocation: selected.targetLocation, rangeFour, rangeFourLocation: selected.rangeFourLocation,
gatherTooFar, gatherTooFarLocation: selected.gatherTooFarLocation,
action, gatherAction: action, rangeAction, rangeFourAction, gatherTooFarAction,
originalInventory, originalPhysical: planetologistDiscoveryFlightPhysical(game),
actions, publicActions: actions, sourceActionCount: actions.length,
staging: [
'Original public G/E/H Basic/Advanced moduleOption Leader Skills + Discoveries, readiness and start, actual Treachery/Traitor deal and original private Planetologist offer/training. The original unopened all14 offer lottery alone selects the requested skill; Advanced retains its explicit preview start.',
...native.staging,
'Actual turn-one paid Guild shipment sends three original reserves to the original Discovery parent. Actual Collection inspection/reveal acquires the original physical Ornithopter token on turn one; native Mentat, next Storm and phases reach turn-two owned movement without a clock or phase install.',
'Actual turn-two one-force paid Guild shipment creates the second physical origin on empty rock. Both real origins have a common empty-rock destination exactly three territories away; the independent range-four and gather over-three negatives remain unplayed.',
'The caller owns the actual Discovery + Planetologist range/gather commit. Fixed-three gather-as-one-action is a visibly provisional composition under the 14 October authority, not a joint publisher FAQ. No public origins or internal receipt is supplied.',
] };
}
export type PlanetologistDiscoveryFlightFixture = ReturnType<typeof createPlanetologistDiscoveryFlightFixture>;
export function finishPlanetologistDiscoveryFlight(fixture: PlanetologistDiscoveryFlightFixture, game: Game,
actions: PlanetologistDiscoveryFlightStep[] = []) {
return advancePlanetologistDiscoveryFlight(game, state => state.turn === fixture.game.turn &&
state.phase === 7 && planetologistDiscoveryFlightClean(state), actions);
}
+199
View File
@@ -0,0 +1,199 @@
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { gameTerritories, location, splitLocation } from '../game/board';
import { botEntryAllowed, botGroundMoveAllowed, groundMoveRouteDistance } from '../game/bot-mobility';
import { presenceAt } from '../game/force-presence';
import { planetologistLeader, type PlanetologistMoveMode } from '../game/planetologist-movement';
import type { OrnithopterMode } from '../game/ornithopter';
import { createSelectedDeckSkillsSetup } from './fixture-selected-deck-skills';
import { nextChoamIndependentSkillsStep } from './fixture-choam-independent-skills';
export const PLANETOLOGIST_ORNITHOPTER = 'richese-ornithopter';
export type PlanetologistOrnithopterStep = { actor: string; action: Action };
export type PlanetologistOrnithopterOptions = {
advanced?: boolean;
mode?: OrnithopterMode;
planetologist?: PlanetologistMoveMode;
secondPlanetologist?: PlanetologistMoveMode;
};
export const planetologistOrnithopterClean = (game: Game) => !game.phaseOpening && !game.response &&
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
export function planetologistOrnithopterPlayer(game: Game, actor = 'emperor') {
const player = game.players.find(candidate => candidate.id === actor);
assert.ok(player, `The original ${actor} seat must remain present`); return player;
}
/** Includes the original physical card's live played zone between groups. */
export function planetologistOrnithopterInventory(game: Game) {
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
...(game.ornithopter ? [game.ornithopter.card] : []),
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])];
const treachery = cards.map(card => card.id).sort();
assert.equal(new Set(treachery).size, treachery.length, 'Each original Treachery Card keeps one physical slot');
return { treachery,
skills: [...game.leaderSkills!.deck, ...game.leaderSkills!.assignments.map(assignment => assignment.skill),
...Object.values(game.leaderSkills!.offers).flatMap(offer => offer.cards)].sort(),
traitors: [...(game.traitorReserve ?? []), ...game.players.flatMap(player => player.traitors)].sort() };
}
export function planetologistOrnithopterStocks(game: Game) {
return game.players.map(player => ({ id: player.id, spice: player.spice, reserves: player.reserves,
tanks: player.tanks, physical: Object.values(player.forces).reduce((sum, count) => sum + count, 0),
eliteReserves: player.elites?.reserves ?? 0, eliteTanks: player.elites?.tanks ?? 0,
elites: Object.values(player.elites?.forces ?? {}).reduce((sum, count) => sum + count, 0),
leaders: structuredClone(player.leaders) }));
}
export function planetologistOrnithopterDestination(action: Action) {
assert.equal(typeof action.territory, 'string'); assert.equal(typeof action.sector, 'number');
return location(action.territory as string, action.sector as number);
}
export function actPlanetologistOrnithopter(game: Game, actor: string, action: Action,
actions: PlanetologistOrnithopterStep[] = []) {
const next = applyAction(game, actor, action);
actions.push({ actor, action: structuredClone(action) }); return next;
}
function nextPlanetologistOrnithopterStep(game: Game): PlanetologistOrnithopterStep | null {
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
const decision = game.decision;
if (decision?.kind === 'richeseCache' && game.richeseCache?.some(card => card.id === PLANETOLOGIST_ORNITHOPTER))
return { actor: decision.player, action: { type: 'decision', event: game.richeseBidding!.event,
card: PLANETOLOGIST_ORNITHOPTER, method: 'onceAround', direction: 'clockwise' } };
if (!decision && game.richeseAuction?.cardId === PLANETOLOGIST_ORNITHOPTER &&
!game.richeseAuction.outcome && game.richeseAuction.active === 'emperor' && game.richeseAuction.bid === 0)
return { actor: 'emperor', action: { type: 'richeseBid', event: game.richeseAuction.event, amount: 1, allyPayment: 0 } };
}
return nextChoamIndependentSkillsStep(game);
}
export function advancePlanetologistOrnithopter(game: Game, until: (state: Game) => boolean,
actions: PlanetologistOrnithopterStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original Planetologist/card chronology ended before its owned boundary');
const step = nextPlanetologistOrnithopterStep(game);
assert.ok(step, `Supply the real ${game.turn}/${game.phase}/${game.decision?.kind} control before continuing`);
game = actPlanetologistOrnithopter(game, step.actor, step.action, actions);
}
throw Error('The native Planetologist/Ornithopter chronology did not reach its finite boundary');
}
function destinations(game: Game, actor: string, origins: readonly string[], distance: number,
empty = true, rocksOnly = true) {
const view = viewGame(game, actor), me = view.players.find(player => player.id === actor)!;
return gameTerritories(game).filter(place => (place.type === 'rock' || (!rocksOnly && place.type === 'sand')) &&
!origins.some(source => splitLocation(source).territory === place.id) &&
(!empty || !game.players.some(player => presenceAt(player, place.id))))
.flatMap(place => place.sectors.map(sector => location(place.id, sector)))
.filter(target => origins.every(source => botEntryAllowed(view, me, splitLocation(target).territory,
splitLocation(target).sector, 'move', splitLocation(source).territory)))
.filter(target => {
const distances = origins.map(source => groundMoveRouteDistance(view, me, source, target));
return distances.every(value => Number.isFinite(value) && value <= distance) && Math.max(...distances) === distance;
});
}
function firstRoute(game: Game, actor: string, sourceA: string, sourceB: string,
mode: OrnithopterMode, skill: PlanetologistMoveMode) {
const origins = skill === 'gather' ? [sourceA, sourceB] : [sourceA];
const range = mode === 'range3' ? 3 : skill === 'range' ? 2 : 1;
const target = destinations(game, actor, origins, range)[0];
const tooFar = destinations(game, actor, origins, range + 1, true, false)[0];
return target && tooFar ? { target, tooFar } : null;
}
/** Original public E/R/A, real living trainer, paid cache1 purchase, original
* funded shipment4 for8 and ordinary split2 earn A2/B2 on turn two. No held
* card, force, wallet, clock, training assignment or flight is installed. */
export function createPlanetologistOrnithopterFixture(options: PlanetologistOrnithopterOptions = {}) {
const advanced = options.advanced ?? false, mode = options.mode ?? 'twoGroups';
const planetologist = options.planetologist ?? 'gather', secondPlanetologist = options.secondPlanetologist ?? planetologist;
const setup = createSelectedDeckSkillsSetup({ advanced, factions: ['emperor', 'richese', 'atreides'],
expansions: ['choam'], skill: 'planetologist' });
const actor = setup.owner, actions: PlanetologistOrnithopterStep[] = [...setup.actions];
let game = structuredClone(setup.afterSetup);
const act = (owner: string, action: Action) => { game = actPlanetologistOrnithopter(game, owner, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advancePlanetologistOrnithopter(game, until, actions); };
const originalInventory = planetologistOrnithopterInventory(game);
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'richeseCache' &&
!!state.richeseCache?.some(card => card.id === PLANETOLOGIST_ORNITHOPTER));
const beforeAcquisition = structuredClone(game), acquisitionStart = actions.length;
reach(state => planetologistOrnithopterClean(state) &&
planetologistOrnithopterPlayer(state, actor).hand.some(card => card.id === PLANETOLOGIST_ORNITHOPTER));
const acquisition = { before: beforeAcquisition, steps: structuredClone(actions.slice(acquisitionStart)), after: structuredClone(game) };
assert.equal(planetologistOrnithopterPlayer(beforeAcquisition, actor).spice - planetologistOrnithopterPlayer(game, actor).spice, 1);
reach(state => state.turn === 1 && state.phase === 5 && state.active === actor && planetologistOrnithopterClean(state));
const view = viewGame(game, actor), me = view.players.find(player => player.id === actor)!;
const rocks = gameTerritories(game).filter(place => place.type === 'rock' &&
!game.players.some(player => presenceAt(player, place.id)))
.flatMap(place => place.sectors.filter(sector => sector >= 7 && sector <= 16 && sector !== game.storm)
.map(sector => location(place.id, sector)));
let geometry: { sourceA: string; sourceB: string } | null = null;
for (const sourceA of rocks) {
for (const sourceB of rocks) {
if (splitLocation(sourceA).territory === splitLocation(sourceB).territory ||
groundMoveRouteDistance(view, me, sourceA, sourceB) !== 1 || !botGroundMoveAllowed(view, me, sourceA, sourceB, 0) ||
!firstRoute(game, actor, sourceA, sourceB, mode, planetologist) ||
!destinations(game, actor, [sourceA, sourceB], 1).length) continue;
geometry = { sourceA, sourceB }; break;
}
if (geometry) break;
}
assert.ok(geometry, 'Actual empty rock origins must support the selected card range and two-origin programme');
const { sourceA, sourceB } = geometry;
const beforeShipment = structuredClone(game), shipmentStep: PlanetologistOrnithopterStep = { actor,
action: { type: 'ship', ...splitLocation(sourceA), amount: 4, elite: 0, allyPayment: 0 } };
act(actor, shipmentStep.action); reach(planetologistOrnithopterClean);
const shipment = { before: beforeShipment, step: shipmentStep, after: structuredClone(game) };
assert.equal(planetologistOrnithopterPlayer(beforeShipment, actor).spice - planetologistOrnithopterPlayer(game, actor).spice, 8);
const beforeSplit = structuredClone(game), splitStep: PlanetologistOrnithopterStep = { actor,
action: { type: 'move', from: sourceA, amount: 2, elite: 0, ...splitLocation(sourceB) } };
act(actor, splitStep.action); reach(planetologistOrnithopterClean);
const splitMovement = { before: beforeSplit, step: splitStep, after: structuredClone(game) };
reach(state => state.turn === 2 && state.phase === 5 && state.active === actor && planetologistOrnithopterClean(state));
assert.equal(planetologistOrnithopterPlayer(game, actor).forces[sourceA], 2);
assert.equal(planetologistOrnithopterPlayer(game, actor).forces[sourceB], 2);
assert.equal(planetologistOrnithopterPlayer(game, actor).moved, 0);
assert.ok(planetologistOrnithopterPlayer(game, actor).hand.some(card => card.id === PLANETOLOGIST_ORNITHOPTER));
const routes = firstRoute(game, actor, sourceA, sourceB, mode, planetologist);
assert.ok(routes, 'The real second-turn board retains its chosen bounded route');
const trainer = planetologistLeader(game, actor); assert.ok(trainer);
const forces: Record<string, number> = planetologist === 'gather' ? { [sourceA]: 1, [sourceB]: 1 } : { [sourceA]: 1 };
const action: Action = { type: 'move', planetologist, forces, movementCard: PLANETOLOGIST_ORNITHOPTER,
ornithopter: mode, ...splitLocation(routes.target) };
const tooFarAction: Action = { ...action, ...splitLocation(routes.tooFar) };
assert.deepEqual(planetologistOrnithopterInventory(game), originalInventory);
return { game, sourceGame: structuredClone(game), initial: setup.initial, started: setup.started,
offered: setup.offered, afterSetup: setup.afterSetup, setup, actor, owner: actor, emperor: actor,
richese: setup.seats.richese!, observer: setup.seats.atreides!, advanced, mode, planetologist, secondPlanetologist,
card: PLANETOLOGIST_ORNITHOPTER, trainer, source: sourceA, sourceA, sourceB,
target: splitLocation(routes.target), targetLocation: routes.target, tooFarLocation: routes.tooFar,
action, tooFarAction, forces, acquisition, shipment, beforeShipment, shipmentStep,
afterShipment: shipment.after, splitMovement, originalInventory, originalStocks: planetologistOrnithopterStocks(game),
actions, sourceActionCount: actions.length, endMovement: { type: 'endMovement' } satisfies Action,
staging: [...setup.staging,
'Original public Emperor/Richese/Atreides +choam/Leader Skills ready/start and actual living Emperor Planetologist training. Only original unopened setup skill/scalar lotteries are selected; no played hand, wallet, force, leader, phase or receipt is installed. Advanced remains preview evidence.',
'The original Richese cache offers its actual physical Ornithopter through a real once-around auction. Emperor actually bids/pays1, then ships four normal counters to non-city rock for8 and makes an actual ordinary range-one split of two counters to adjacent rock. Native end-turn and later phase controls earn turn-two A2/B2 and retain the same purchased card.',
'Selected provisional composition: each two-origin gather counts as one card movement. A fixed range3 applies to every gather origin and stays3 with the range alternative; the twoGroups mode uses current normal range with one chosen Planetologist alternative for each action. Original unmoved cohorts constrain both source maps and do not grow from merged first arrivals. This is not a retrieved combined publisher FAQ.',
'Stop before the first flight declaration. Caller-owned actions commit first/second groups, their actual source maps, one original discard, movement credit and original Collection continuation.',
] };
}
export type PlanetologistOrnithopterFixture = ReturnType<typeof createPlanetologistOrnithopterFixture>;
export function movePlanetologistOrnithopter(fixture: PlanetologistOrnithopterFixture,
game = structuredClone(fixture.game), actions: PlanetologistOrnithopterStep[] = []) {
game = actPlanetologistOrnithopter(game, fixture.actor, fixture.action, actions);
return advancePlanetologistOrnithopter(game, planetologistOrnithopterClean, actions);
}
export function planetologistOrnithopterSecondAction(fixture: PlanetologistOrnithopterFixture,
game: Game, skill: PlanetologistMoveMode = fixture.secondPlanetologist): Action {
assert.equal(game.ornithopter?.mode, 'twoGroups'); assert.equal(game.ornithopter.completed, 1);
const forces: Record<string, number> = skill === 'gather'
? { [fixture.sourceA]: 1, [fixture.sourceB]: 1 }
: { [fixture.planetologist === 'gather' ? fixture.sourceA : fixture.sourceB]: 1 };
const origins = Object.keys(forces);
const target = skill === 'gather' && fixture.planetologist === 'gather'
? fixture.targetLocation : destinations(game, fixture.actor, origins, skill === 'range' ? 2 : 1)[0];
assert.ok(target, 'The actual remaining original cohort must have a legal second movement');
return { type: 'move', planetologist: skill, forces, ornithopterEvent: game.ornithopter.event, ...splitLocation(target) };
}
export function finishPlanetologistOrnithopter(fixture: PlanetologistOrnithopterFixture, game: Game,
actions: PlanetologistOrnithopterStep[] = []) {
return advancePlanetologistOrnithopter(game, state => state.turn === fixture.game.turn &&
state.phase === 7 && planetologistOrnithopterClean(state), actions);
}
@@ -0,0 +1,179 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { location, splitLocation } from '../game/board';
import { groundMoveRouteDistance } from '../game/bot-mobility';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { discoveryFlightMove } from '../game/discovery-flight-options';
import { planetologistLeader } from '../game/planetologist-movement';
import {
actPlanetologistDiscoveryFlight as act,
createPlanetologistDiscoveryFlightFixture as fixture,
finishPlanetologistDiscoveryFlight as finish,
planetologistDiscoveryFlightInventory as inventory,
planetologistDiscoveryFlightPhysical as physical,
planetologistDiscoveryFlightPlayer as player,
type PlanetologistDiscoveryFlightFixture as Fixture,
type PlanetologistDiscoveryFlightStep as Step,
} from './fixture-planetologist-discovery-flight';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
}
function source(f: Fixture) {
assert.equal(f.initial.status, 'lobby'); assert.equal(f.ready.status, 'lobby'); assert.equal(f.started.status, 'setup');
assert.equal(f.collection.status, 'playing'); assert.equal(f.game.status, 'playing');
assert.equal(f.game.advanced, f.advanced); assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 5);
assert.equal(f.game.active, f.actor); assert.equal(f.game.phaseOpening ?? null, null);
assert.equal(f.game.response ?? null, null); assert.equal(f.game.decision ?? null, null);
assert.deepEqual(f.game.expansions, []); assert.deepEqual(f.game.players.map(p => p.faction), ['guild', 'emperor', 'harkonnen']);
assert.ok(f.game.leaderSkills && f.game.discoveryEnabled && f.game.discoveries);
assert.ok(!f.game.homeworlds && !f.game.nexusCards && !f.game.techTokens && !f.game.strongholdCards && !f.game.ecazTreachery);
assert.ok(f.actions.some(step => step.action.type === 'moduleOption' && step.action.module === 'leaderSkills'));
assert.ok(f.actions.some(step => step.action.type === 'moduleOption' && step.action.module === 'discoveries'));
assert.ok(f.actions.some(step => step.action.type === 'start' && (!f.advanced || step.action.advancedPreview === true)));
assert.ok(f.actions.some(step => step.actor === f.actor && step.action.type === 'leaderSkill' &&
step.action.skill === 'planetologist' && step.action.leader === f.trainer));
assert.ok(f.actions.some(step => step.actor === f.actor && step.action.type === 'discovery' &&
step.action.token === f.token && step.action.reveal === true));
assert.equal(f.collection.turn, 1); assert.equal(f.collection.phase, 7);
assert.equal(f.acquired.turn, 1); assert.equal(f.originalToken.acquiredTurn, 1); assert.equal(f.originalToken.revealedTurn, 1);
assert.equal(f.originalToken.face, 'ornithopter'); assert.equal(f.originalToken.status, 'carried'); assert.equal(f.originalToken.owner, f.actor);
assert.deepEqual(f.game.discoveries!.tokens.find(token => token.id === f.token), f.originalToken);
assert.match(viewGame(f.acquired, f.actor).discoveryOrnithopter!.blocked!, /after/);
assert.equal(viewGame(f.game, f.actor).discoveryOrnithopter!.blocked, null);
assert.equal(viewGame(f.game, f.observer).discoveryOrnithopter, null);
assert.equal(planetologistLeader(f.game, f.actor), f.trainer);
assert.equal(player(f.game, f.actor).moved, 0); assert.equal(player(f.game, f.actor).shipped, true);
assert.equal(player(f.game, f.actor).forces[f.first], 3); assert.equal(player(f.game, f.actor).forces[f.second], 1);
assert.equal(player(f.game, f.actor).forces['tueks_sietch:5'], 5, 'The original off-route Guild garrison remains physical');
assert.equal(f.beforeShipment.turn, 2); assert.equal(f.shipmentStep.action.type, 'ship'); assert.equal(f.shipmentStep.action.amount, 1);
assert.equal(player(f.beforeShipment, f.actor).spice - player(f.afterShipment, f.actor).spice, 1);
assert.equal(player(f.beforeShipment, f.actor).reserves - player(f.afterShipment, f.actor).reserves, 1);
assert.notEqual(splitLocation(f.first).territory, splitLocation(f.second).territory);
const view = viewGame(f.game, f.actor), me = view.players.find(p => p.id === f.actor)!;
for (const from of f.sources) assert.equal(groundMoveRouteDistance(view, me, from, f.targetLocation), 3);
assert.equal(groundMoveRouteDistance(view, me, f.first, f.rangeFourLocation), 4);
assert.ok(f.sources.some(from => groundMoveRouteDistance(view, me, from, f.gatherTooFarLocation) === 4));
assert.equal(Object.hasOwn(f.action, 'origins'), false); assert.equal(Object.hasOwn(f.action, 'discoveryFlight'), false);
assert.equal(f.sourceActionCount, f.actions.length); assert.ok(f.sourceActionCount > 20);
assert.deepEqual(inventory(f.game), f.originalInventory); assert.equal(f.originalInventory.treachery.length, 33);
assert.equal(f.originalInventory.skills.length, 14); assert.equal(f.originalInventory.discoveries.length, 8);
}
function moved(f: Fixture, game: Game, action: Action) {
const before = player(f.game, f.actor), after = player(game, f.actor);
const forces = action.forces !== undefined ? action.forces as Record<string, number>
: { [action.from as string]: action.amount as number };
const destination = location(action.territory as string, action.sector as number);
const expected = { ...before.forces };
for (const [from, amount] of Object.entries(forces)) {
expected[from] -= amount; if (!expected[from]) delete expected[from];
}
expected[destination] = (expected[destination] ?? 0) + Object.values(forces).reduce((sum, amount) => sum + amount, 0);
assert.deepEqual(after.forces, expected);
assert.equal(after.moved, before.moved + 1); assert.equal(after.shipped, before.shipped);
assert.equal(after.reserves, before.reserves); assert.equal(after.tanks, before.tanks); assert.equal(after.spice, before.spice);
assert.deepEqual(after.elites, before.elites); assert.deepEqual(after.leaders, before.leaders); assert.deepEqual(after.hand, before.hand);
assert.deepEqual(game.leaderSkills, f.game.leaderSkills); assert.equal(planetologistLeader(game, f.actor), f.trainer);
assert.deepEqual(game.players.filter(p => p.id !== f.actor), f.game.players.filter(p => p.id !== f.actor));
const token = game.discoveries!.tokens.find(token => token.id === f.token)!;
assert.equal(token.status, 'removed'); assert.equal(token.owner, null); assert.equal(token.acquiredTurn, 1);
assert.equal(token.revealedTurn, 1); assert.equal(viewGame(game, f.actor).discoveryOrnithopter, null);
assert.deepEqual(game.discoveries!.tokens.filter(token => token.id !== f.token),
f.game.discoveries!.tokens.filter(token => token.id !== f.token));
assert.deepEqual(inventory(game), f.originalInventory);
}
for (const advanced of [false, true]) {
const band = advanced ? 'Advanced preview' : 'Basic';
void test(`public ${band} Discovery plus Planetologist range stays fixed at three and consumes one token after the accepted movement`, () => {
const f = fixture({ advanced }); source(f);
rejected(f.game, f.actor, f.rangeFourAction);
const ordinary = { ...f.rangeAction }; delete ordinary.discoveryOrnithopter;
rejected(f.game, f.actor, ordinary);
const quote = discoveryFlightMove(viewGame(f.game, f.actor), ordinary);
assert.equal(quote.blocked, null); assert.deepEqual(quote.action, f.rangeAction);
const game = act(f.game, f.actor, f.rangeAction); moved(f, game, f.rangeAction);
rejected(game, f.actor, f.rangeAction); rejected(game, f.actor, f.action);
const actions: Step[] = [], done = finish(f, game, actions);
assert.equal(done.turn, 2); assert.equal(done.phase, 7);
assert.ok(actions.some(step => step.actor === f.actor && step.action.type === 'endMovement'));
assert.equal(player(done, f.actor).moved, 1); assert.equal(player(done, f.actor).forces[f.targetLocation], 1);
assert.equal(done.discoveries!.tokens.find(token => token.id === f.token)!.status, 'removed');
assert.deepEqual(inventory(done), f.originalInventory);
});
void test(`public ${band} Discovery plus Planetologist gathers both actual funded origins at fixed three as one ordinary action`, () => {
const f = fixture({ advanced }); source(f);
rejected(f.game, f.actor, f.gatherTooFarAction);
const ordinary = { ...f.action }; delete ordinary.discoveryOrnithopter;
rejected(f.game, f.actor, ordinary);
const quote = discoveryFlightMove(viewGame(f.game, f.actor), ordinary);
assert.equal(quote.blocked, null); assert.deepEqual(quote.action, f.action);
const before = structuredClone(f.game), game = act(f.game, f.actor, f.action);
assert.deepEqual(f.game, before); moved(f, game, f.action);
assert.equal(player(game, f.actor).forces[f.first] ?? 0, 0); assert.equal(player(game, f.actor).forces[f.second] ?? 0, 0);
assert.equal(player(game, f.actor).forces[f.targetLocation], 4);
rejected(game, f.actor, f.action);
const actions: Step[] = [], done = finish(f, game, actions);
assert.equal(done.turn, 2); assert.equal(done.phase, 7); assert.equal(player(done, f.actor).moved, 1);
assert.equal(player(done, f.actor).forces[f.targetLocation], 4);
assert.ok(actions.some(step => step.actor === f.actor && step.action.type === 'endMovement'));
assert.deepEqual(inventory(done), f.originalInventory);
});
}
void test('the native acquired token rejects same-turn, stale, foreign, malformed and changed-source declarations without consuming it', () => {
const f = fixture(); source(f);
const sameTurn = discoveryFlightMove(viewGame(f.acquired, f.actor), f.rangeAction);
assert.equal(sameTurn.action, null); assert.match(sameTurn.blocked!, /after/);
rejected(f.acquired, f.actor, f.rangeAction);
rejected(f.game, f.observer, f.action); rejected(f.game, f.opponent, f.action);
for (const action of [
{ ...f.action, discoveryOrnithopter: 'discovery-token-99' },
{ ...f.action, discoveryOrnithopter: f.game.discoveries!.tokens.find(token => token.id !== f.token)!.id },
{ ...f.action, forces: { [f.first]: 4, [f.second]: 1 } },
{ ...f.action, forces: { [f.first]: 3 } },
{ ...f.action, forces: { [f.first]: 3, [f.second]: 1, 'tueks_sietch:5': 1 } },
{ ...f.action, forces: { [f.first]: -1, [f.second]: 1 } },
{ ...f.action, origins: f.sources.map(from => splitLocation(from).territory) },
{ ...f.action, discoveryFlight: { token: f.token } },
{ ...f.action, ornithopterEvent: 'stale-card-event' },
]) rejected(f.game, f.actor, action);
assert.deepEqual(physical(f.game), f.originalPhysical);
assert.deepEqual(f.game.discoveries!.tokens.find(token => token.id === f.token), f.originalToken);
});
void test('controlled eligibility and changed-source supplements cannot reuse the original native combined declaration', () => {
const f = fixture(); source(f);
// CONTROLLED supplements: these do not claim played trainer deaths/captures,
// a new action receipt or casualties in the original positive chronology.
for (const state of ['dead', 'captured'] as const) {
const controlled = structuredClone(f.game), trainer = player(controlled, f.actor).leaders.find(disc => disc.id === f.trainer)!;
if (state === 'dead') trainer.dead = true; else trainer.capturedBy = f.observer;
assert.equal(planetologistLeader(controlled, f.actor), null); rejected(controlled, f.actor, f.action);
}
const changed = structuredClone(f.game), me = player(changed, f.actor);
me.forces[f.first]--; me.tanks++;
assert.equal(me.reserves + me.tanks + Object.values(me.forces).reduce((sum, amount) => sum + amount, 0), 20);
rejected(changed, f.actor, f.action);
assert.deepEqual(physical(f.game), f.originalPhysical); assert.deepEqual(inventory(f.game), f.originalInventory);
});
void test('all four minimal profiles offer legal fixed-three range and gather Discovery compositions at both native sources', () => {
for (const advanced of [false, true]) {
const f = fixture({ advanced }); source(f);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(f.game, f.actor); view.players.find(p => p.id === f.actor)!.bot = difficulty;
const actions = botActions(view);
for (const mode of ['range', 'gather'] as const) {
const action = actions.find(action => action.type === 'move' && action.planetologist === mode && action.discoveryOrnithopter === f.token);
assert.ok(action, `${difficulty} must offer the owned ${mode} Discovery composition`);
assert.equal(Object.hasOwn(action, 'origins'), false); assert.equal(Object.hasOwn(action, 'discoveryFlight'), false);
const game = act(f.game, f.actor, action); moved(f, game, action);
assert.equal(player(game, f.actor).moved, 1);
}
}
}
});
@@ -0,0 +1,289 @@
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<GameView['discoveries']>;
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<string, number> = { [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<GameView['richeseNoField']>;
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<GameView['response']>; },
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(`<option value="${value}"`));
assert.match(html, /Movement transport/);
assert.match(html, /aria-label="Provisional Planetologist flight composition"/);
assert.match(html, /fixed-range Ornithopter and Discovery flight stay at three territories for either skill choice/);
assert.match(html, /A gather counts as one card movement/);
assert.match(html, /different original forces/);
const second = markup(active(projected()));
assert.match(second, /1 card movements completed · 1 remaining/);
assert.match(second, /Select original unmoved forces below/);
assert.match(second, /Wind Pass, sector 14 · 1 forces/);
assert.doesNotMatch(second, /Wind Pass, sector 14 · 2 forces/);
});
void test('selection and actual flight callbacks use projected capabilities without private hands reserves or opponent inventories', () => {
for (const flight of ['range3', 'twoGroups', 'discovery'] as const) {
const game = flight === 'discovery' ? carried(projected()) : projected();
for (const player of game.players) for (const field of ['hand', 'traitors', 'faceDancers', 'spice', 'reserves'])
Object.defineProperty(player, field, { get() { throw Error(`Flight controls read unrelated ${player.id}.${field}`); } });
const props = propsFor(game, flight);
assert.deepEqual(click(props), [expected(props)]);
assert.match(markup(game), /Provisional Planetologist flight composition/);
}
});
@@ -0,0 +1,18 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame } from '../game/engine';
import { planetologistMovementModeSupported } from '../game/planetologist-movement';
import { createDiscoveryNativeE2SkillsFixture } from './fixture-discovery-native-e2-skills';
void test('native Discovery skill projections retain the existing enabled module for movement eligibility', () => {
for (const advanced of [false, true]) {
const f = createDiscoveryNativeE2SkillsFixture({ advanced });
assert.equal(f.game.discoveryEnabled, true);
assert.equal(planetologistMovementModeSupported(f.game), true);
for (const actor of [f.richese, f.choam, f.guild]) {
const projected = viewGame(f.game, actor);
assert.equal(projected.lobbyModules, null);
assert.equal(planetologistMovementModeSupported(projected), true);
}
}
});
@@ -78,7 +78,7 @@ void test('the owned marker has a usable checkbox origin and persistent provisio
assert.match(html, /value stays concealed/);
assert.match(html, /No direct joint FAQ settles this combination/);
assert.match(html, /No physical board forces are available to move/);
assert.match(html, /Concealed No-Fields cannot join either Ornithopter card movement or Discovery flight/);
assert.match(html, /Only one flight source may be used for each movement/);
assert.doesNotMatch(html, /controlled-zero|controlled-three|controlled-five|Confirm|Acknowledge|countdown/);
}
assert.deepEqual(game, before);
@@ -0,0 +1,217 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { splitLocation, territory } from '../game/board';
import { groundMoveRouteDistance } from '../game/bot-mobility';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { planetologistLeader } from '../game/planetologist-movement';
import {
actPlanetologistOrnithopter as act, advancePlanetologistOrnithopter as advance,
createPlanetologistOrnithopterFixture as fixture, finishPlanetologistOrnithopter as finish,
movePlanetologistOrnithopter as move, planetologistOrnithopterClean as clean,
planetologistOrnithopterDestination as destination, planetologistOrnithopterInventory as inventory,
planetologistOrnithopterPlayer as player, planetologistOrnithopterSecondAction as secondAction,
planetologistOrnithopterStocks as stocks, type PlanetologistOrnithopterFixture,
} from './fixture-planetologist-ornithopter';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
}
function ownView(game: Game, actor: string) { return viewGame(game, actor).players.find(seat => seat.id === actor)!; }
function selected(action: Action): Record<string, number> {
assert.ok(action.forces && typeof action.forces === 'object' && !Array.isArray(action.forces));
for (const count of Object.values(action.forces)) assert.equal(typeof count, 'number');
return action.forces as Record<string, number>;
}
function expectedForces(game: Game, actor: string, action: Action) {
const forces = structuredClone(player(game, actor).forces);
for (const [key, count] of Object.entries(selected(action))) {
forces[key] -= count; if (!forces[key]) delete forces[key];
}
const target = destination(action);
forces[target] = (forces[target] ?? 0) + Object.values(selected(action)).reduce((sum, count) => sum + count, 0);
return forces;
}
function source(f: PlanetologistOrnithopterFixture) {
assert.equal(f.game.status, 'playing'); assert.equal(f.game.advanced, f.advanced);
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 5); assert.equal(f.game.active, f.actor);
assert.deepEqual(f.game.players.map(seat => seat.faction), ['emperor', 'richese', 'atreides']);
assert.deepEqual(f.game.expansions, ['choam']); assert.ok(f.game.leaderSkills);
assert.ok(!f.game.homeworlds && !f.game.nexusCards && !f.game.techTokens &&
!f.game.strongholdCards && !f.game.discoveryEnabled && !f.game.ecazTreachery);
assert.ok(clean(f.game));
assert.equal(planetologistLeader(f.game, f.actor), f.trainer);
assert.ok(f.actions.some(step => step.action.type === 'start'));
assert.ok(f.actions.some(step => step.actor === f.actor && step.action.type === 'leaderSkill' &&
step.action.skill === 'planetologist' && step.action.leader === f.trainer));
assert.ok(f.acquisition.steps.some(step => step.actor === f.actor && step.action.type === 'richeseBid' && step.action.amount === 1));
assert.equal(player(f.acquisition.before, f.actor).spice - player(f.acquisition.after, f.actor).spice, 1);
assert.ok(player(f.acquisition.before, f.richese).spice < player(f.acquisition.after, f.richese).spice);
assert.ok(player(f.game, f.actor).hand.some(card => card.id === f.card));
assert.equal(f.shipment.before.turn, 1); assert.equal(f.shipment.step.action.type, 'ship');
assert.equal(f.shipment.step.action.amount, 4); assert.equal(f.shipment.step.action.elite, 0);
assert.equal(player(f.shipment.before, f.actor).spice - player(f.shipment.after, f.actor).spice, 8);
assert.equal(player(f.shipment.before, f.actor).reserves - player(f.shipment.after, f.actor).reserves, 4);
assert.equal(f.splitMovement.step.action.type, 'move'); assert.equal(f.splitMovement.step.action.amount, 2);
assert.equal(f.splitMovement.step.action.planetologist, undefined);
assert.equal(player(f.splitMovement.after, f.actor).moved, 1);
assert.deepEqual(player(f.game, f.actor).forces, { [f.sourceA]: 2, [f.sourceB]: 2 });
assert.equal(player(f.game, f.actor).reserves, 16); assert.equal(player(f.game, f.actor).tanks, 0);
assert.equal(player(f.game, f.actor).moved, 0); assert.equal(ownView(f.game, f.actor).movesAllowed, 1);
assert.equal(player(f.game, f.actor).ornithopterExtraMoves ?? 0, 0);
assert.equal(f.game.ornithopter ?? null, null);
assert.equal(territory(splitLocation(f.sourceA).territory).type, 'rock');
assert.equal(territory(splitLocation(f.sourceB).territory).type, 'rock');
assert.equal(Object.hasOwn(f.action, 'origins'), false);
const view = viewGame(f.game, f.actor), me = view.players.find(seat => seat.id === f.actor)!;
const origins = Object.keys(f.forces), distances = origins.map(from => groundMoveRouteDistance(view, me, from, f.targetLocation));
const range = f.mode === 'range3' ? 3 : f.planetologist === 'range' ? 2 : 1;
assert.equal(Math.max(...distances), range);
assert.ok(distances.every(distance => distance <= range));
assert.equal(f.sourceActionCount, f.actions.length);
assert.deepEqual(inventory(f.game), f.originalInventory);
}
function complete(f: PlanetologistOrnithopterFixture, game: Game, count: 1 | 2) {
assert.equal(player(game, f.actor).moved, count);
assert.equal(ownView(game, f.actor).movesAllowed, count);
assert.equal(player(game, f.actor).ornithopterExtraMoves ?? 0, count - 1);
assert.equal(game.ornithopter ?? null, null);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
assert.equal(player(game, f.actor).hand.filter(card => card.id === f.card).length, 0);
assert.deepEqual(stocks(game), f.originalStocks);
assert.deepEqual(game.leaderSkills, f.game.leaderSkills);
assert.deepEqual(inventory(game), f.originalInventory);
}
for (const advanced of [false, true]) for (const planetologist of ['range', 'gather'] as const) {
void test(`public ${advanced ? 'Advanced' : 'Basic'} physical range3 with Planetologist ${planetologist} stays capped at three and spends one movement`, () => {
const f = fixture({ advanced, mode: 'range3', planetologist }); source(f);
rejected(f.game, f.actor, f.tooFarAction);
const before = structuredClone(f.game), expected = expectedForces(f.game, f.actor, f.action);
let game = move(f); assert.deepEqual(f.game, before);
assert.deepEqual(player(game, f.actor).forces, expected); complete(f, game, 1);
rejected(game, f.actor, f.action);
const counters = structuredClone(player(game, f.actor).forces), steps: { actor: string; action: Action }[] = [];
game = finish(f, game, steps);
assert.equal(game.phase, 7); assert.equal(game.turn, 2);
assert.ok(steps.some(step => step.actor === f.actor && step.action.type === 'endMovement'));
assert.deepEqual(player(game, f.actor).forces, counters);
assert.equal(player(game, f.actor).spice, player(f.game, f.actor).spice);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
assert.deepEqual(inventory(game), f.originalInventory);
});
}
for (const advanced of [false, true]) for (const planetologist of ['range', 'gather'] as const)
for (const secondPlanetologist of ['range', 'gather'] as const) {
void test(`public ${advanced ? 'Advanced' : 'Basic'} twoGroups changes Planetologist ${planetologist} to ${secondPlanetologist} with distinct original cohorts`, () => {
const f = fixture({ advanced, mode: 'twoGroups', planetologist, secondPlanetologist }); source(f);
rejected(f.game, f.actor, f.tooFarAction);
const firstExpected = expectedForces(f.game, f.actor, f.action);
let game = move(f);
assert.deepEqual(player(game, f.actor).forces, firstExpected);
assert.equal(player(game, f.actor).moved, 1); assert.equal(ownView(game, f.actor).movesAllowed, 2);
assert.equal(player(game, f.actor).ornithopterExtraMoves ?? 0, 0);
assert.equal(game.ornithopter?.mode, 'twoGroups'); assert.equal(game.ornithopter.completed, 1);
const expectedCohort = { [f.sourceA]: 2 - f.forces[f.sourceA], [f.sourceB]: 2 - (f.forces[f.sourceB] ?? 0) };
assert.deepEqual(game.ornithopter.cohort!.forces, expectedCohort,
'The first card group removes every selected origin map from the original cohort');
assert.deepEqual(game.ornithopter.cohort!.elites, {});
assert.equal(player(game, f.actor).hand.some(card => card.id === f.card), false);
assert.equal(game.discard.some(card => card.id === f.card), false);
assert.deepEqual(inventory(game), f.originalInventory);
rejected(game, f.actor, { type: 'move', planetologist: 'range', forces: { [f.targetLocation]: 1 },
ornithopterEvent: game.ornithopter.event, ...splitLocation(f.sourceA) });
const action = secondAction(f, game), expected = expectedForces(game, f.actor, action);
assert.equal(action.planetologist, secondPlanetologist);
assert.equal(Object.hasOwn(action, 'origins'), false);
rejected(game, f.actor, { ...action, ornithopterEvent: 'stale-card-event' });
game = act(game, f.actor, action); game = advance(game, clean);
assert.deepEqual(player(game, f.actor).forces, expected); complete(f, game, 2);
if (planetologist === 'gather' && secondPlanetologist === 'gather')
assert.deepEqual(player(game, f.actor).forces, { [f.targetLocation]: 4 },
'The same-origin residual units may legitimately merge with the first gather without reusing its units');
rejected(game, f.actor, action);
const counters = structuredClone(player(game, f.actor).forces);
game = finish(f, game);
assert.equal(game.phase, 7); assert.equal(game.turn, 2);
assert.deepEqual(player(game, f.actor).forces, counters);
assert.equal(player(game, f.actor).spice, player(f.game, f.actor).spice);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
assert.deepEqual(inventory(game), f.originalInventory);
});
}
void test('original residual quotas survive merging a first card arrival into a later source stack', () => {
const f = fixture({ mode: 'twoGroups', planetologist: 'range' }); source(f);
const first: Action = { ...f.action, ...splitLocation(f.sourceB) };
let game = act(f.game, f.actor, first); game = advance(game, clean);
assert.equal(player(game, f.actor).forces[f.sourceB], 3);
assert.equal(game.ornithopter!.cohort!.forces[f.sourceB], 2);
const nativeSecond = secondAction(f, game, 'range');
rejected(game, f.actor, { ...nativeSecond, forces: { [f.sourceB]: 3 } });
const second: Action = { ...nativeSecond, forces: { [f.sourceB]: 2 } };
game = act(game, f.actor, second); game = advance(game, clean);
complete(f, game, 2);
assert.equal(player(game, f.actor).forces[f.sourceB], 1, 'The first moved unit stays behind at its new location');
assert.equal(player(game, f.actor).forces[destination(second)], 2);
});
void test('ending after one accepted gather discards the actual card without earning an abandoned second-group credit', () => {
for (const advanced of [false, true]) {
const f = fixture({ advanced, mode: 'twoGroups', planetologist: 'gather' }); source(f);
let game = move(f);
const counters = structuredClone(player(game, f.actor).forces);
game = act(game, f.actor, f.endMovement); game = advance(game, state => clean(state) && state.active !== f.actor);
assert.equal(game.ornithopter ?? null, null);
assert.equal(player(game, f.actor).ornithopterExtraMoves ?? 0, 0);
assert.equal(player(game, f.actor).moved, 1);
assert.deepEqual(player(game, f.actor).forces, counters);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
game = finish(f, game);
assert.equal(game.phase, 7); assert.deepEqual(player(game, f.actor).forces, counters);
assert.deepEqual(inventory(game), f.originalInventory);
}
});
void test('physical Planetologist declarations reject foreign, stale and untrained controlled selections before spending the original card', () => {
const f = fixture({ mode: 'range3', planetologist: 'gather' }); source(f);
rejected(f.game, f.observer, f.action);
rejected(f.game, f.actor, { ...f.action, ornithopterEvent: 'foreign-card-event' });
rejected(f.game, f.actor, { ...f.action, movementCard: 'foreign-card' });
rejected(f.game, f.actor, { ...f.action, origins: [splitLocation(f.sourceA).territory, splitLocation(f.sourceB).territory] });
rejected(f.game, f.actor, { ...f.action, planetologist: { mode: 'gather', leader: f.trainer } });
// Controlled trainer inputs supplement the twelve genuine public programmes;
// they are not claimed to be played casualty/capture histories.
for (const state of ['dead', 'captured'] as const) {
const controlled = structuredClone(f.game), leader = player(controlled, f.actor).leaders.find(disc => disc.id === f.trainer)!;
if (state === 'dead') leader.dead = true;
else leader.capturedBy = f.observer;
assert.equal(planetologistLeader(controlled, f.actor), null);
rejected(controlled, f.actor, f.action);
}
assert.deepEqual(stocks(f.game), f.originalStocks);
assert.ok(player(f.game, f.actor).hand.some(card => card.id === f.card));
});
void test('all four minimal profiles offer physical card and current second-group Planetologist alternatives', () => {
for (const advanced of [false, true]) for (const mode of ['range3', 'twoGroups'] as const)
for (const planetologist of ['range', 'gather'] as const) {
const f = fixture({ advanced, mode, planetologist }); source(f);
const sources = [f.game, ...(mode === 'twoGroups' ? [move(f)] : [])];
for (const game of sources) for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, f.actor); view.players.find(seat => seat.id === f.actor)!.bot = difficulty;
const candidates = botActions(view).filter(action => action.type === 'move' && action.planetologist === planetologist &&
(game.ornithopter ? action.ornithopterEvent === game.ornithopter.event
: action.movementCard === f.card && action.ornithopter === mode));
assert.ok(candidates.length, `${difficulty} offers ${mode}/${planetologist}/${game.ornithopter ? 'second' : 'initial'} movement`);
const action = candidates[0], before = player(game, f.actor).moved;
let accepted = act(game, f.actor, action); accepted = advance(accepted, clean);
assert.equal(player(accepted, f.actor).moved, before + 1);
assert.deepEqual(stocks(accepted), stocks(game));
assert.deepEqual(inventory(accepted), f.originalInventory);
}
}
});