import assert from 'node:assert/strict'; import { viewGame, type Action, type Game } from '../game/engine'; import { gameTerritories, location, splitLocation } from '../game/board'; import { groundMoveRouteDistance } from '../game/bot-mobility'; import { presenceAt } from '../game/force-presence'; import { sandmasterLeader, sandmasterRouteDistance, sandmasterShortestRoute, type SandmasterChoice } from '../game/sandmaster-movement'; import type { OrnithopterMode } from '../game/ornithopter'; import { createSelectedDeckSkillsSetup } from './fixture-selected-deck-skills'; import { nextChoamIndependentSkillsStep } from './fixture-choam-independent-skills'; import { actPlanetologistOrnithopter, planetologistOrnithopterClean, planetologistOrnithopterDestination, planetologistOrnithopterInventory, planetologistOrnithopterPlayer, planetologistOrnithopterStocks, type PlanetologistOrnithopterStep, } from './fixture-planetologist-ornithopter'; export const SANDMASTER_ORNITHOPTER = 'richese-ornithopter'; export type SandmasterOrnithopterStep = PlanetologistOrnithopterStep; export const actSandmasterOrnithopter = actPlanetologistOrnithopter; export const sandmasterOrnithopterClean = planetologistOrnithopterClean; export const sandmasterOrnithopterPlayer = planetologistOrnithopterPlayer; export const sandmasterOrnithopterStocks = planetologistOrnithopterStocks; export const sandmasterOrnithopterDestination = planetologistOrnithopterDestination; export function sandmasterOrnithopterInventory(game: Game) { return { ...planetologistOrnithopterInventory(game), spice: [...game.spiceDeck, ...game.spiceDiscard.flat()].map(card => JSON.stringify(card)).sort() }; } /** Real cache producer and original private bid; no hand/card substitution. */ export function nextSandmasterOrnithopterStep(game: Game): SandmasterOrnithopterStep | null { if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) { const decision = game.decision; if (decision?.kind === 'richeseCache' && game.richeseCache?.some(card => card.id === SANDMASTER_ORNITHOPTER)) return { actor: decision.player, action: { type: 'decision', event: game.richeseBidding!.event, card: SANDMASTER_ORNITHOPTER, method: 'onceAround', direction: 'clockwise' } }; if (!decision && game.richeseAuction?.cardId === SANDMASTER_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 advanceSandmasterOrnithopter(game: Game, until: (state: Game) => boolean, actions: SandmasterOrnithopterStep[] = []): Game { for (let count = 0; count < 1800; count++) { if (until(game)) return game; assert.notEqual(game.status, 'finished', 'The original Sandmaster/card chronology ended before its owned boundary'); const step = nextSandmasterOrnithopterStep(game); assert.ok(step, `Supply the real ${game.turn}/${game.phase}/${game.decision?.kind} control before continuing`); game = actSandmasterOrnithopter(game, step.actor, step.action, actions); } throw Error('The native Sandmaster/Ornithopter chronology did not reach its finite boundary'); } /** A legal, deliberately chosen three-territory route through the real pile. * Prefer a destination with a shorter direct route to prove route retention. */ function rangeThreeRoute(game: Game, actor: string, source: string, pile: string) { const prefix = sandmasterShortestRoute(game, actor, source, pile, 1); if (!prefix || sandmasterRouteDistance(game, prefix) !== 1) return null; const view = viewGame(game, actor), me = view.players.find(player => player.id === actor)!; const candidates: { target: string; route: string[]; direct: number }[] = []; for (const place of gameTerritories(game)) { if (place.type !== 'rock' || place.id === splitLocation(source).territory || place.id === splitLocation(pile).territory || game.players.some(player => presenceAt(player, place.id)) || Object.entries(game.spice).some(([key, amount]) => amount > 0 && splitLocation(key).territory === place.id)) continue; for (const sector of place.sectors) { const target = location(place.id, sector), tail = sandmasterShortestRoute(game, actor, pile, target, 2); if (!tail || sandmasterRouteDistance(game, tail) !== 2) continue; const route = [...prefix, ...tail.slice(1)]; if (new Set(route).size !== route.length || sandmasterRouteDistance(game, route) !== 3) continue; candidates.push({ target, route, direct: groundMoveRouteDistance(view, me, source, target) }); } } return candidates.sort((a, b) => Number(b.direct < 3) - Number(a.direct < 3) || a.target.localeCompare(b.target))[0] ?? null; } /** Original public E/R/A, living native Sandmaster, actual cache1 purchase and * shipment4 for8. Only the still-unopened turn-two Spice order is selected; * its real blow creates the positive pile. No played stock or clock is installed. */ export function createSandmasterOrnithopterFixture(options: { advanced?: boolean; mode?: OrnithopterMode } = {}) { const advanced = options.advanced ?? false, mode = options.mode ?? 'twoGroups'; const setup = createSelectedDeckSkillsSetup({ advanced, factions: ['emperor', 'richese', 'atreides'], expansions: ['choam'], skill: 'sandmaster' }); const actor = setup.owner, actions: SandmasterOrnithopterStep[] = [...setup.actions]; let game = structuredClone(setup.afterSetup); const act = (owner: string, action: Action) => { game = actSandmasterOrnithopter(game, owner, action, actions); }; const reach = (until: (state: Game) => boolean) => { game = advanceSandmasterOrnithopter(game, until, actions); }; const originalInventory = sandmasterOrnithopterInventory(game); reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'richeseCache' && !!state.richeseCache?.some(card => card.id === SANDMASTER_ORNITHOPTER)); const beforeAcquisition = structuredClone(game), acquisitionStart = actions.length; reach(state => sandmasterOrnithopterClean(state) && sandmasterOrnithopterPlayer(state, actor).hand.some(card => card.id === SANDMASTER_ORNITHOPTER)); const acquisition = { before: beforeAcquisition, steps: structuredClone(actions.slice(acquisitionStart)), after: structuredClone(game) }; assert.equal(sandmasterOrnithopterPlayer(beforeAcquisition, actor).spice - sandmasterOrnithopterPlayer(game, actor).spice, 1); reach(state => state.turn === 1 && state.phase === 5 && state.active === actor && sandmasterOrnithopterClean(state)); let selected: { source: string; card: Extract } | null = null; for (const place of gameTerritories(game)) { if (place.type !== 'rock' || game.players.some(player => presenceAt(player, place.id)) || Object.entries(game.spice).some(([key, amount]) => amount > 0 && splitLocation(key).territory === place.id)) continue; for (const sector of place.sectors) { if (sector < 7 || sector > 16 || sector === game.storm) continue; const source = location(place.id, sector); for (const card of game.spiceDeck) { if (!('territory' in card) || card.discovery || card.sector < 7 || card.sector > 16 || card.territory === place.id || !sandmasterShortestRoute(game, actor, source, location(card.territory, card.sector), 1)) continue; const ranged = rangeThreeRoute(game, actor, source, location(card.territory, card.sector)); if (!ranged || ranged.direct >= 3) continue; selected = { source, card: structuredClone(card) }; break; } if (selected) break; } if (selected) break; } assert.ok(selected, 'A real still-unopened positive Spice blow must be adjacent to an affordable safe rock source'); const selectedCard = selected.card; const source = selected.source, sourceLocation = splitLocation(source), pile = location(selectedCard.territory, selectedCard.sector); const beforeShipment = structuredClone(game), shipmentStep: SandmasterOrnithopterStep = { actor, action: { type: 'ship', ...sourceLocation, amount: 4, elite: 0, allyPayment: 0 } }; act(actor, shipmentStep.action); reach(sandmasterOrnithopterClean); const shipment = { before: beforeShipment, step: shipmentStep, after: structuredClone(game) }; assert.equal(sandmasterOrnithopterPlayer(beforeShipment, actor).spice - sandmasterOrnithopterPlayer(game, actor).spice, 8); reach(state => state.turn === 2 && state.phase === 0 && sandmasterOrnithopterClean(state)); const beforeBlow = structuredClone(game), spiceOriginal = structuredClone(game.spiceDeck); const at = game.spiceDeck.findIndex(card => JSON.stringify(card) === JSON.stringify(selectedCard)); assert.ok(at >= 0, 'The selected printed Spice card is still unopened'); const first = game.spiceDeck.splice(at, 1)[0]; const front: Game['spiceDeck'] = [first]; if (advanced) { const next = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery && card.territory !== selectedCard.territory && card.territory !== sourceLocation.territory); assert.ok(next >= 0); front.push(game.spiceDeck.splice(next, 1)[0]); } game.spiceDeck.unshift(...front); const spiceOrder = { original: spiceOriginal, selected: structuredClone(front), after: structuredClone(game.spiceDeck) }; assert.deepEqual(spiceOrder.after.map(card => JSON.stringify(card)).sort(), spiceOriginal.map(card => JSON.stringify(card)).sort()); const blowStart = actions.length; reach(state => state.turn === 2 && state.phase === 2 && sandmasterOrnithopterClean(state)); const blow = { before: beforeBlow, steps: structuredClone(actions.slice(blowStart)), after: structuredClone(game), card: selectedCard }; assert.ok(game.spiceDiscard.flat().some(card => JSON.stringify(card) === JSON.stringify(selectedCard))); assert.equal(game.spice[pile], (beforeBlow.spice[pile] ?? 0) + selectedCard.amount, 'The real printed Spice card creates this pile before any caller-owned flight'); reach(state => state.turn === 2 && state.phase === 5 && state.active === actor && sandmasterOrnithopterClean(state)); const trainer = sandmasterLeader(game, actor); assert.ok(trainer); assert.deepEqual(sandmasterOrnithopterPlayer(game, actor).forces, { [source]: 4 }); assert.equal(sandmasterOrnithopterPlayer(game, actor).moved, 0); const ranged = rangeThreeRoute(game, actor, source, pile); assert.ok(ranged); const ordinaryRoute = sandmasterShortestRoute(game, actor, source, pile, 1); assert.ok(ordinaryRoute); const targetLocation = mode === 'range3' ? ranged.target : pile; const route = mode === 'range3' ? ranged.route : ordinaryRoute; const choice: SandmasterChoice = { routes: { [source]: route }, collect: [pile] }; const action: Action = { type: 'move', forces: { [source]: 2 }, movementCard: SANDMASTER_ORNITHOPTER, ornithopter: mode, sandmaster: choice, ...splitLocation(targetLocation) }; assert.deepEqual(sandmasterOrnithopterInventory(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, card: SANDMASTER_ORNITHOPTER, trainer, source, sourceLocation, target: splitLocation(targetLocation), targetLocation, route: structuredClone(route), routes: structuredClone(choice.routes), choice, action, pile, pileBefore: game.spice[pile], originalPiles: structuredClone(game.spice), ranged, acquisition, shipment, beforeShipment, shipmentStep, afterShipment: shipment.after, beforeBlow, blow, spiceOrder, originalInventory, originalStocks: sandmasterOrnithopterStocks(game), originalSandmasterLogCount: game.log.filter(entry => entry.text.includes("Sandmaster collected 1 spice while moving")).length, actions, sourceActionCount: actions.length, endMovement: { type: 'endMovement' } satisfies Action, staging: [...setup.staging, 'Original public Emperor/Richese/Atreides +choam/Leader Skills module, actual original Sandmaster offer/training and real cache Ornithopter1 sale. Four normal counters actually ship to empty non-city rock for8 and remain there across native turn progression. No played hand, force, wallet, leader, pile, phase or receipt is installed; Advanced remains preview evidence.', 'Only original unopened setup lotteries and the still-unopened turn-two ordinary Spice order are selected. The actual printed Spice blow creates a positive pile adjacent to the real four-counter army; native Storm/Spice/Charity/Bidding/Revival controls reach the next original owned movement turn.', 'Selected composition permits the printed moving-forces Sandmaster trigger during both physical card modes. Fixed card range remains3; each twoGroups action uses its own normal range and a distinct original cohort. One spice per entered territory resets per accepted movement under the existing explicit provisional interpretation; owner pile allocation stays provisional.', 'The caller owns route collection, each actual movement, original residual cohort, card disposal and Collection continuation. A legal explicit three-territory route through the real pile may be longer than the shortest direct route. The twoGroups case uses two original two-counter subsets and may revisit the same still-positive pile for its separate second movement.', ] }; } export type SandmasterOrnithopterFixture = ReturnType; export function moveSandmasterOrnithopter(fixture: SandmasterOrnithopterFixture, game = structuredClone(fixture.game), actions: SandmasterOrnithopterStep[] = [], collect = true) { const action: Action = collect ? fixture.action : { ...fixture.action, sandmaster: { routes: fixture.choice.routes, collect: [] } }; game = actSandmasterOrnithopter(game, fixture.actor, action, actions); return advanceSandmasterOrnithopter(game, sandmasterOrnithopterClean, actions); } export function sandmasterOrnithopterSecondAction(fixture: SandmasterOrnithopterFixture, game: Game, collect = true): Action { assert.equal(game.ornithopter?.mode, 'twoGroups'); assert.equal(game.ornithopter.completed, 1); assert.equal(sandmasterOrnithopterPlayer(game, fixture.actor).forces[fixture.source], 2); const route = sandmasterShortestRoute(game, fixture.actor, fixture.source, fixture.pile, 1); assert.ok(route); return { type: 'move', forces: { [fixture.source]: 2 }, ornithopterEvent: game.ornithopter.event, sandmaster: { routes: { [fixture.source]: route }, collect: collect ? [fixture.pile] : [] }, ...splitLocation(fixture.pile) }; } export function finishSandmasterOrnithopter(fixture: SandmasterOrnithopterFixture, game: Game, actions: SandmasterOrnithopterStep[] = []) { return advanceSandmasterOrnithopter(game, state => state.turn === fixture.game.turn && state.phase === 7 && sandmasterOrnithopterClean(state), actions); }