150 lines
11 KiB
TypeScript
150 lines
11 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { applyAction, createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
|
import { gameTerritories, location, splitLocation } from '../game/board';
|
|
import { presenceAt } from '../game/force-presence';
|
|
import {
|
|
sandmasterCollectionPiles, sandmasterEnteredTerritories, sandmasterLeader,
|
|
sandmasterRouteDistance, sandmasterShortestRoute, type SandmasterChoice,
|
|
} from '../game/sandmaster-movement';
|
|
import { skillOfferRolls } from './fixture-independent-card-modules';
|
|
import {
|
|
advanceClassicDiscoverySkills, classicDiscoverySkillsClean, createClassicDiscoverySkillsFixture,
|
|
revealClassicDiscoverySkills,
|
|
} from './fixture-discovery-classic-skills';
|
|
import {
|
|
planetologistDiscoveryFlightInventory, planetologistDiscoveryFlightPhysical,
|
|
} from './fixture-planetologist-discovery-flight';
|
|
|
|
export type SandmasterDiscoveryFlightStep = { actor: string; action: Action };
|
|
export const sandmasterDiscoveryFlightClean = classicDiscoverySkillsClean;
|
|
export const sandmasterDiscoveryFlightInventory = planetologistDiscoveryFlightInventory;
|
|
export const sandmasterDiscoveryFlightPhysical = planetologistDiscoveryFlightPhysical;
|
|
export function sandmasterDiscoveryFlightPlayer(game: Game, actor = 'guild') {
|
|
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
|
|
}
|
|
export function actSandmasterDiscoveryFlight(game: Game, actor: string, action: Action,
|
|
actions: SandmasterDiscoveryFlightStep[] = []): Game {
|
|
const next = applyAction(game, actor, action);
|
|
actions.push({ actor, action: structuredClone(action) }); return next;
|
|
}
|
|
export function advanceSandmasterDiscoveryFlight(game: Game, until: (state: Game) => boolean,
|
|
actions: SandmasterDiscoveryFlightStep[] = []): Game {
|
|
return advanceClassicDiscoverySkills(game, until, actions);
|
|
}
|
|
/** Select the first original skill lottery, before any technique is trained. */
|
|
function originalOffers<T>(game: Game, run: () => T): T {
|
|
const rolls = skillOfferRolls(game, { guild: 'sandmaster', 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');
|
|
}
|
|
}
|
|
/** Plan against the actual second Storm. Only one original still-unplayed
|
|
* positive Spice Card is selected; it must fund a genuinely entered territory. */
|
|
function selectBlowRoute(game: Game, actor: string, source: string) {
|
|
for (const place of gameTerritories(game)) {
|
|
if (place.type !== 'rock' || game.players.some(player => presenceAt(player, place.id))) continue;
|
|
for (const sector of place.sectors) {
|
|
const targetLocation = location(place.id, sector), route = sandmasterShortestRoute(game, actor, source, targetLocation, 3);
|
|
if (!route || sandmasterRouteDistance(game, route) !== 3) continue;
|
|
const entered = sandmasterEnteredTerritories({ [source]: route });
|
|
const card = game.spiceDeck.find(card => 'territory' in card && !card.discovery &&
|
|
card.sector !== game.storm && entered.includes(card.territory));
|
|
if (card && 'territory' in card) return { route, targetLocation, card: structuredClone(card), pile: location(card.territory, card.sector) };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Public G/E/H Skills + Discoveries, actual Sandmaster offer/training and an
|
|
* actual turn-one paid collector acquire the original token in Collection.
|
|
* Native Storm and the next real Spice blow supply the later positive route.
|
|
* Only original unopened lotteries/unplayed cards are selected. No played
|
|
* token, army, wallet, trainer, phase, receipt or spice pile is installed. */
|
|
export function createSandmasterDiscoveryFlightFixture(options: { advanced?: boolean } = {}) {
|
|
const advanced = options.advanced ?? false, actor = 'guild', observer = 'emperor', opponent = 'harkonnen';
|
|
let game = createGame('SANDMASTERDISCOVERYFLIGHT', newPlayer(actor, 'Guild', 'guild'), advanced);
|
|
joinGame(game, newPlayer(observer, 'Emperor', 'emperor'));
|
|
joinGame(game, newPlayer(opponent, 'Harkonnen', 'harkonnen'));
|
|
const actions: SandmasterDiscoveryFlightStep[] = [], initial = structuredClone(game);
|
|
const act = (owner: string, action: Action) => { game = actSandmasterDiscoveryFlight(game, owner, action, actions); };
|
|
const reach = (until: (state: Game) => boolean) => { game = advanceSandmasterDiscoveryFlight(game, until, 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: 'sandmaster', face: 'ornithopter', amount: 1 });
|
|
actions.push(...native.actions.map(step => structuredClone(step)));
|
|
// Retain the original successful deal and actions; never replay setup's RNG.
|
|
game = native.game; const collection = structuredClone(game);
|
|
const source = location(native.parent, native.parentSector), sourcePile = source;
|
|
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);
|
|
reach(state => state.turn === 2 && state.phase === 1 && sandmasterDiscoveryFlightClean(state));
|
|
const beforeBlowSelection = structuredClone(game), selected = selectBlowRoute(game, actor, source);
|
|
assert.ok(selected, 'A real unplayed normal Spice Card must fund an entered territory on an exact-three route to empty rock');
|
|
const index = game.spiceDeck.findIndex(card => JSON.stringify(card) === JSON.stringify(selected.card)); assert.ok(index >= 0);
|
|
game.spiceDeck.unshift(game.spiceDeck.splice(index, 1)[0]);
|
|
if (advanced) {
|
|
const second = game.spiceDeck.findIndex((card, index) => index > 0 && 'territory' in card && !card.discovery &&
|
|
card.territory !== splitLocation(source).territory && card.territory !== selected.card.territory);
|
|
assert.ok(second > 0); game.spiceDeck.splice(1, 0, game.spiceDeck.splice(second, 1)[0]);
|
|
}
|
|
const beforeBlow = structuredClone(game);
|
|
reach(state => state.turn === 2 && state.phase === 5 && state.active === actor && sandmasterDiscoveryFlightClean(state));
|
|
assert.ok(game.spice[selected.pile] > 0, 'The selected real next-turn Spice blow must remain available before movement');
|
|
const beforeShipment = structuredClone(game), shipmentStep: SandmasterDiscoveryFlightStep = { actor,
|
|
action: { type: 'ship', ...splitLocation(source), amount: 1, elite: 0, allyPayment: 0 } };
|
|
act(actor, shipmentStep.action); reach(sandmasterDiscoveryFlightClean);
|
|
const afterShipment = structuredClone(game), sourceCount = sandmasterDiscoveryFlightPlayer(game, actor).forces[source];
|
|
assert.equal(sourceCount, 2);
|
|
assert.equal(sandmasterDiscoveryFlightPlayer(beforeShipment, actor).spice - sandmasterDiscoveryFlightPlayer(game, actor).spice, 1);
|
|
assert.equal(sandmasterDiscoveryFlightPlayer(beforeShipment, actor).reserves - sandmasterDiscoveryFlightPlayer(game, actor).reserves, 1);
|
|
const trainer = sandmasterLeader(game, actor); assert.ok(trainer);
|
|
const routes = { [source]: selected.route }, offeredPiles = sandmasterCollectionPiles(game, routes);
|
|
const choice: SandmasterChoice = { routes, collect: Object.values(offeredPiles).map(piles => piles[0]) };
|
|
assert.ok(choice.collect.includes(selected.pile)); assert.ok(!choice.collect.includes(sourcePile));
|
|
const target = splitLocation(selected.targetLocation);
|
|
const action: Action = { type: 'move', from: source, amount: sourceCount, elite: 0,
|
|
discoveryOrnithopter: native.token, sandmaster: choice, ...target };
|
|
const declineChoice: SandmasterChoice = { routes: structuredClone(routes), collect: [] };
|
|
const declineAction: Action = { ...action, sandmaster: declineChoice };
|
|
const ordinary: Action = { ...action }; delete ordinary.discoveryOrnithopter;
|
|
const plainAction: Action = { ...action }; delete plainAction.sandmaster;
|
|
const originalInventory = sandmasterDiscoveryFlightInventory(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,
|
|
beforeBlowSelection, beforeBlow, blowCard: selected.card, beforeShipment, shipmentStep, afterShipment,
|
|
source, sourcePile, sourceSpice: game.spice[sourcePile] ?? 0, sourceCount,
|
|
route: selected.route, routes, target, targetLocation: selected.targetLocation,
|
|
pile: selected.pile, piles: choice.collect, offeredPiles, choice, declineChoice, action, declineAction, ordinary, plainAction,
|
|
originalInventory, originalPhysical: sandmasterDiscoveryFlightPhysical(game), originalSpice: structuredClone(game.spice),
|
|
actions, publicActions: actions, sourceActionCount: actions.length,
|
|
staging: [
|
|
'Original public G/E/H Basic/Advanced Skills + Discoveries module selections, readiness and start; original all14 offer lottery selects Sandmaster before actual private training. Advanced keeps its explicit preview start. No post-hoc setup replay occurs.',
|
|
...native.staging,
|
|
'Actual turn-one one-force paid Guild shipment reaches the real Discovery parent. Actual inspect/reveal acquires the original physical Ornithopter on turn one; native Collection retains its actual source-spice result, which may be zero. Native Mentat, next Storm and the real phase-one opening follow.',
|
|
'Only a still-unplayed ordinary Spice Card is reordered at that real second-turn Spice opening to fund a territory entered by the selected exact-three route; Advanced also puts a separate original ordinary card second. Actual native Spice resolution creates the positive pile. No spice, force, wallet, phase or token is installed.',
|
|
'Actual turn-two paid one-force Guild shipment joins the original source counter. Stop before the fixed-three Discovery movement to empty rock; optional collection, one native movement/token consumption and the later Collection continuation remain caller-owned. This empty-rock route has no natural opposing movement-response frame.',
|
|
] };
|
|
}
|
|
export type SandmasterDiscoveryFlightFixture = ReturnType<typeof createSandmasterDiscoveryFlightFixture>;
|
|
export function finishSandmasterDiscoveryFlight(fixture: SandmasterDiscoveryFlightFixture, game: Game,
|
|
actions: SandmasterDiscoveryFlightStep[] = []) {
|
|
return advanceSandmasterDiscoveryFlight(game, state => state.turn === fixture.game.turn &&
|
|
state.phase === 7 && sandmasterDiscoveryFlightClean(state), actions);
|
|
}
|