Files
Dune/tests/fixture-betrayal-sandmaster-route.ts
T

125 lines
6.5 KiB
TypeScript

import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { botEntryAllowed } from '../game/bot-mobility';
import { TERRITORIES, location, splitLocation } from '../game/board';
import { drawNexusCard } from '../game/nexus-cards';
import {
sandmasterActionOrder, sandmasterShortestRoute, type SandmasterChoice,
} from '../game/sandmaster-movement';
import { createGuildBetrayalSkillsSetup } from './fixture-bureaucrat-betrayal-funding';
export const sandmasterTrainer = (game: Game) => game.players.find(player => player.id === 'trainer')!;
/** Only unplayed physical card ordering/custody is controlled. All setup, piles,
* counters, wallets, movement budgets and Guild invoices use native actions. */
export function createBetrayalSandmasterRoute() {
const setup = createGuildBetrayalSkillsSetup(false, false, 'sandmaster');
let game = setup.trained;
const inventory = game.nexusCards!.cards!;
assert.ok(Object.values(inventory.hands).every(card => card === null));
const guildIndex = inventory.deck.indexOf('guild');
assert.ok(guildIndex >= 0);
inventory.deck.unshift(inventory.deck.splice(guildIndex, 1)[0]);
const controlledNexusOrder = [...inventory.deck];
// Preserve the original singleton inventory, arranging its unplayed custody
// before any shipment, exactly as the original Guild Betrayal fixture does.
game.nexusCards!.cards = drawNexusCard(inventory, 'holder', game.players, () => 0);
let orderedSpice = false;
let printedPile = '';
let printedAmount = 0;
let controlledSpiceOrder: typeof game.spiceDeck = [];
let betrayalPasses = 0;
const step = (state: Game): Game => {
if (state.pendingGuildBetrayal) {
const frame = state.pendingGuildBetrayal;
const actor = frame.required.find(id => !frame.passed.includes(id))!;
betrayalPasses++;
return applyAction(state, actor, { type: 'guildBetrayalPass', event: frame.invoice.event });
}
if (state.response) {
const actor = state.players.find(player => !state.response!.passed.includes(player.id))!.id;
return applyAction(state, actor, { type: 'passResponse' });
}
if (state.decision?.kind === 'guildShipment')
return applyAction(state, state.decision.player, { type: 'decision', allow: true });
if (state.decision?.kind === 'advisor')
return applyAction(state, state.decision.player, { type: 'decision', accept: false });
for (const player of state.players) {
const view = viewGame(state, player.id);
view.players.find(seat => seat.id === player.id)!.bot = 'Easy';
const proposed = botActions(view)[0];
if (!proposed) continue;
const action: Action = proposed.type === 'stormDial' ? { ...proposed, amount: 0 } :
proposed.type === 'bid' ? { type: 'passBid' } : proposed;
return applyAction(state, player.id, action);
}
throw new Error(`Native Sandmaster fixture stalled at ${state.status}/${state.phase}/${state.decision?.kind}`);
};
for (let steps = 0; steps < 700; steps++) {
if (game.status === 'playing' && game.phase === 1 && !orderedSpice) {
assert.equal(game.spiceDiscard.flat().length, 0);
const index = game.spiceDeck.findIndex(card => 'territory' in card &&
sandmasterShortestRoute(game, 'trainer', 'polar_sink:0', location(card.territory, card.sector), 1));
assert.ok(index >= 0, 'A printed first blow is adjacent to the original BG starting counter.');
const card = game.spiceDeck.splice(index, 1)[0];
assert.ok('territory' in card);
printedPile = location(card.territory, card.sector);
printedAmount = card.amount;
const secondIndex = game.spiceDeck.findIndex(other => 'territory' in other);
assert.ok(secondIndex >= 0);
const second = game.spiceDeck.splice(secondIndex, 1)[0];
game.spiceDeck.unshift(card, second);
controlledSpiceOrder = structuredClone(game.spiceDeck);
orderedSpice = true;
}
if (game.status === 'playing' && game.phase === 5 && !game.phaseOpening &&
!game.response && !game.decision && !game.pendingGuildBetrayal) break;
game = step(game);
}
assert.equal(game.phase, 5);
assert.ok(!game.phaseOpening);
assert.ok(orderedSpice);
assert.equal(game.spice[printedPile], printedAmount, 'The original printed blow created the pile.');
assert.equal(game.nexusCards!.cards!.hands.holder, 'guild', 'The conserved physical Guild face remains in recorded custody.');
let shipped = false;
for (let steps = 0; steps < 100 && game.active !== 'trainer'; steps++) {
if (game.pendingGuildBetrayal || game.response || game.decision) {
game = step(game);
continue;
}
const actor = game.active!;
if (!shipped && actor !== 'guild') {
const view = viewGame(game, actor);
const payer = view.players.find(player => player.id === actor)!;
const destination = TERRITORIES.filter(t => t.type === 'stronghold').flatMap(t =>
t.sectors.map(sector => ({ territory: t.id, sector }))).find(to =>
botEntryAllowed(view, payer, to.territory, to.sector, 'ship'));
assert.ok(destination);
game = applyAction(game, actor, { type: 'ship', ...destination, amount: 1 });
assert.ok(game.pendingGuildBetrayal, 'An unrelated ordinary shipment retains the original Betrayal gate.');
shipped = true;
} else game = applyAction(game, actor, { type: 'endMovement' });
}
while (game.pendingGuildBetrayal || game.response || game.decision) game = step(game);
assert.equal(game.active, 'trainer');
assert.ok(betrayalPasses > 0);
const trainer = sandmasterTrainer(game);
assert.equal(trainer.forces['polar_sink:0'], 1, 'The real original starting counter remains on the board.');
assert.equal(trainer.reserves, 19);
assert.equal(trainer.advisors?.polar_sink, undefined, 'Basic BG starting counter is a fighter.');
const at = splitLocation(printedPile);
const ordinary: Action = { type: 'move', from: 'polar_sink:0', amount: 1, territory: at.territory, sector: at.sector };
const order = sandmasterActionOrder(viewGame(game, 'trainer'), 'trainer', ordinary);
assert.ok(order);
const route = sandmasterShortestRoute(game, 'trainer', 'polar_sink:0', printedPile, order.range);
assert.ok(route);
const choice: SandmasterChoice = { routes: { 'polar_sink:0': route }, collect: [printedPile] };
const action: Action = { ...ordinary, sandmaster: choice };
return { game, ordinary, action, order, choice, printedPile, printedAmount,
betrayalPasses, controlledNexusOrder, controlledSpiceOrder };
}