Files
Dune/tests/fixture-planetologist-discovery-flight.ts
T

163 lines
12 KiB
TypeScript

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);
}