Enable Sandmaster collection on flight and marker routes

This commit is contained in:
Quintine committed 2026-10-26 10:11:18 +11:00
1 parent 4af90e547c
commit ec549028ba
20 files changed
+1623 -165

No files matched your search

@@ -0,0 +1,149 @@
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);
}
+33
View File
@@ -0,0 +1,33 @@
import assert from 'node:assert/strict';
import { viewGame, type Action } from '../game/engine';
import { createRicheseNoField, deployRicheseNoField, type NoFieldValue } from '../game/richese-no-field';
import { sandmasterDefaultChoice } from '../game/sandmaster-movement';
import { smugglerShipmentGame } from './smuggler-shipment-fixture';
import { sandmasterMove } from './sandmaster-movement-fixture';
/** Controlled conserved movement/pile supplement, not a played shipment source.
* Native flight fixtures separately earn their actual sources and Spice blows. */
export function createSandmasterMarkerFixture(value: NoFieldValue = 0, physical = false, advanced = false) {
const game = smugglerShipmentGame('richese', advanced, 'sandmaster', 'guild', ['choam']);
const actor = 'p', player = game.players.find(seat => seat.id === actor)!;
player.forces = { 'red_chasm:7': 3, 'arrakeen:10': 1 };
player.reserves = 16; player.tanks = 0; player.shipped = true; player.moved = 0;
const state = player.noField ?? createRicheseNoField(['controlled-marker-0', 'controlled-marker-3', 'controlled-marker-5']);
const token = state.tokens.find(candidate => candidate.value === value); assert.ok(token);
player.noField = deployRicheseNoField(state, { tokenId: token.id, controller: actor,
location: { territory: 'red_chasm', sector: 7 } });
player.noFieldEvent = 'controlled-sandmaster-marker-event';
game.spice = {};
const route = sandmasterMove(game, 2);
const action: Action = { type: 'move', forces: physical ? { 'red_chasm:7': 3 } : {},
noField: token.id, event: player.noFieldEvent, territory: route.territory, sector: route.sector };
const choice = sandmasterDefaultChoice(viewGame(game, actor), actor, action); assert.ok(choice);
action.sandmaster = choice;
assert.equal(Object.keys(choice.routes).length, 1, 'Mixed marker/physical source shares one declared route');
assert.equal(choice.collect.length, 2);
return { game, actor, token, value, physical, advanced, action, choice,
source: 'red_chasm:7', target: `${String(action.territory)}:${String(action.sector)}`,
originalNoField: structuredClone(player.noField), originalEvent: player.noFieldEvent,
originalSpice: structuredClone(game.spice), originalWallet: player.spice,
staging: 'Explicitly controlled physical positions, deployed original token and positive piles; no native shipment/Spice chronology claimed.' };
}
+187
View File
@@ -0,0 +1,187 @@
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<Game['spiceDeck'][number], { territory: string }> } | 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<typeof createSandmasterOrnithopterFixture>;
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);
}
@@ -0,0 +1,213 @@
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 { DIFFICULTIES } from '../game/bot-profiles';
import { botActions } from '../game/bots';
import { discoveryFlightMove } from '../game/discovery-flight-options';
import { quoteDiscoveryFlight, validateDiscoveryFlight } from '../game/discovery-flight';
import {
quoteSandmasterMovement, sandmasterActionOrder, sandmasterDefaultChoice,
sandmasterEnteredTerritories, sandmasterLeader, sandmasterRouteDistance,
validateSandmasterMovement, type SandmasterChoice,
} from '../game/sandmaster-movement';
import {
actSandmasterDiscoveryFlight as act,
createSandmasterDiscoveryFlightFixture as fixture,
finishSandmasterDiscoveryFlight as finish,
sandmasterDiscoveryFlightInventory as inventory,
sandmasterDiscoveryFlightPhysical as physical,
sandmasterDiscoveryFlightPlayer as player,
type SandmasterDiscoveryFlightFixture as Fixture,
type SandmasterDiscoveryFlightStep as Step,
} from './fixture-sandmaster-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.collection.turn, 1); assert.equal(f.collection.phase, 7);
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);
for (const selectedModule of ['leaderSkills', 'discoveries']) assert.ok(f.actions.some(step => step.action.type === 'moduleOption' && step.action.module === selectedModule));
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 === 'sandmaster' && step.action.leader === f.trainer));
assert.equal(sandmasterLeader(f.game, f.actor), f.trainer);
assert.equal(f.originalToken.face, 'ornithopter'); assert.equal(f.originalToken.owner, f.actor);
assert.equal(f.originalToken.status, 'carried'); assert.equal(f.originalToken.acquiredTurn, 1);
assert.equal(f.originalToken.revealedTurn, 1);
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(f.beforeBlow.turn, 2); assert.equal(f.beforeBlow.phase, 1); assert.deepEqual(f.beforeBlow.spiceDeck[0], f.blowCard);
assert.ok(f.game.spiceDiscard.flat().some(card => JSON.stringify(card) === JSON.stringify(f.blowCard)), 'The selected physical Spice Card actually resolved');
assert.ok(f.game.spice[f.pile] >= f.blowCard.amount, 'The real next-turn blow funds the entered pile');
assert.equal(f.sourceSpice, f.originalSpice[f.sourcePile] ?? 0);
assert.equal(player(f.collection, f.actor).forces[f.source], 1);
assert.equal(f.sourceCount, 2); assert.equal(player(f.game, f.actor).forces[f.source], 2);
assert.equal(player(f.game, f.actor).forces['tueks_sietch:5'], 5);
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.equal(player(f.game, f.actor).moved, 0); assert.equal(player(f.game, f.actor).shipped, true);
assert.equal(f.route[0], f.source); assert.equal(f.route.at(-1), f.targetLocation);
assert.equal(sandmasterRouteDistance(f.game, f.route), 3);
assert.ok(sandmasterEnteredTerritories(f.routes).includes(splitLocation(f.pile).territory));
assert.equal(sandmasterEnteredTerritories(f.routes).includes(splitLocation(f.source).territory), false);
assert.ok(f.choice.collect.includes(f.pile)); assert.equal(f.choice.collect.includes(f.sourcePile), false);
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 expected = { ...before.forces }, destination = location(action.territory as string, action.sector as number);
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);
const choice = action.sandmaster as SandmasterChoice | undefined, selected = choice?.collect ?? [];
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 + selected.length);
assert.deepEqual(after.elites, before.elites); assert.deepEqual(after.leaders, before.leaders); assert.deepEqual(after.hand, before.hand);
assert.deepEqual(game.players.filter(p => p.id !== f.actor), f.game.players.filter(p => p.id !== f.actor));
const spice = { ...f.originalSpice };
for (const key of selected) spice[key]--;
assert.deepEqual(game.spice, spice);
assert.deepEqual(game.leaderSkills, f.game.leaderSkills); assert.equal(sandmasterLeader(game, f.actor), f.trainer);
assert.equal(game.discoveries!.tokens.find(token => token.id === f.token)!.status, 'removed');
assert.equal(game.discoveries!.tokens.find(token => token.id === f.token)!.owner, null);
assert.equal(game.discoveries!.tokens.find(token => token.id === f.token)!.acquiredTurn, 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.equal(game.log.slice(f.game.log.length).filter(entry => entry.automatic?.name === 'Sandmaster collection').length, selected.length);
assert.deepEqual(inventory(game), f.originalInventory);
}
for (const advanced of [false, true]) {
const band = advanced ? 'Advanced preview' : 'Basic';
void test(`public ${band} Sandmaster uses one acquired Discovery flight to collect real entered-territory spice at fixed three`, () => {
const f = fixture({ advanced }); source(f);
rejected(f.game, f.actor, f.ordinary);
const view = viewGame(f.game, f.actor), quote = discoveryFlightMove(view, f.ordinary);
assert.equal(quote.blocked, null); assert.deepEqual(quote.action, f.action);
assert.deepEqual(sandmasterDefaultChoice(view, f.actor, f.action), f.choice);
const game = act(f.game, f.actor, f.action); moved(f, game, f.action);
assert.equal(game.spice[f.sourcePile], f.originalSpice[f.sourcePile], 'The actual source-spice quantity stays unchanged');
assert.equal(game.spice[f.pile], f.originalSpice[f.pile] - 1);
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], f.sourceCount);
assert.equal(done.spice[f.sourcePile], f.originalSpice[f.sourcePile]);
assert.equal(done.discoveries!.tokens.find(token => token.id === f.token)!.status, 'removed');
assert.ok(actions.some(step => step.actor === f.actor && step.action.type === 'endMovement'));
assert.deepEqual(inventory(done), f.originalInventory);
});
void test(`public ${band} Sandmaster can decline collection while its original Discovery token still funds exactly one movement`, () => {
const f = fixture({ advanced }); source(f);
for (const action of [f.declineAction, f.plainAction]) {
const game = act(f.game, f.actor, action); moved(f, game, action);
assert.deepEqual(game.spice, f.originalSpice); assert.equal(player(game, f.actor).spice, player(f.game, f.actor).spice);
const done = finish(f, game); assert.equal(done.turn, 2); assert.equal(done.phase, 7);
assert.equal(player(done, f.actor).moved, 1); assert.deepEqual(inventory(done), f.originalInventory);
}
});
}
void test('native Sandmaster Discovery declarations reject same-turn, stale token, foreign caller, route and collection errors immutably', () => {
const f = fixture(); source(f);
const sameTurn = discoveryFlightMove(viewGame(f.acquired, f.actor), f.action);
assert.equal(sameTurn.action, null); assert.match(sameTurn.blocked!, /after/);
rejected(f.acquired, f.actor, f.action); 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, amount: f.sourceCount + 1 },
{ ...f.action, sandmaster: { ...f.choice, collect: [...f.choice.collect, f.sourcePile] } },
{ ...f.action, sandmaster: { ...f.choice, collect: [...f.choice.collect, f.pile] } },
{ ...f.action, sandmaster: { ...f.choice, collect: ['not_a_spice_territory:0'] } },
{ ...f.action, sandmaster: { ...f.choice, routes: { [f.source]: [f.source, f.targetLocation] } } },
{ ...f.action, sandmaster: { ...f.choice, routes: {} } },
{ ...f.action, discoveryFlight: { token: f.token } },
{ ...f.action, origins: [splitLocation(f.source).territory] },
]) rejected(f.game, f.actor, action);
assert.deepEqual(physical(f.game), f.originalPhysical); assert.deepEqual(f.game.spice, f.originalSpice);
assert.deepEqual(f.game.discoveries!.tokens.find(token => token.id === f.token), f.originalToken);
});
void test('controlled native-trainer, live-pile and pending-quote supplements retain the original Discovery-bound Sandmaster order', () => {
const f = fixture(); source(f); const view = viewGame(f.game, f.actor);
const order = sandmasterActionOrder(view, f.actor, f.action); assert.ok(order);
assert.deepEqual(order.flight, { kind: 'discovery', token: f.token, acquiredTurn: 1 });
const receipt = quoteSandmasterMovement(f.game, f.actor, order, f.choice);
validateSandmasterMovement(f.game, f.actor, order, receipt);
// CONTROLLED helper supplements: the positive empty-rock chronology has no
// native opposing response. These do not claim a played death, capture,
// pile debit or pending engine frame in that chronology.
const changedPile = structuredClone(f.game); changedPile.spice[f.pile]--;
assert.throws(() => validateSandmasterMovement(changedPile, f.actor, order, receipt));
// CONTROLLED positive-source exclusion only: the native source may be empty.
// This does not claim that another native Spice blow or Collection left spice.
const positiveSource = structuredClone(f.game);
positiveSource.spice[f.sourcePile] = (positiveSource.spice[f.sourcePile] ?? 0) + 1;
rejected(positiveSource, f.actor, { ...f.action,
sandmaster: { ...f.choice, collect: [...f.choice.collect, f.sourcePile] } });
const changedOrder = structuredClone(order); changedOrder.group[0][1]--;
assert.throws(() => validateSandmasterMovement(f.game, f.actor, changedOrder, receipt));
const noFlight = structuredClone(order); delete noFlight.flight;
assert.throws(() => validateSandmasterMovement(f.game, f.actor, noFlight, receipt));
for (const flight of [
{ kind: 'discovery' as const, token: 'discovery-token-99', acquiredTurn: 1 },
{ kind: 'discovery' as const, token: f.token, acquiredTurn: 2 },
{ kind: 'physical' as const, card: 'foreign-card', mode: 'range3' as const, completed: 0 },
]) assert.throws(() => validateSandmasterMovement(f.game, f.actor, { ...order, flight }, receipt));
assert.throws(() => validateSandmasterMovement(f.game, f.actor, order, { ...receipt, move: receipt.move + 1 }));
assert.throws(() => validateSandmasterMovement(f.game, f.actor, order, { ...receipt, leader: 'foreign-trainer' }));
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(sandmasterLeader(controlled, f.actor), null);
rejected(controlled, f.actor, f.action);
assert.throws(() => validateSandmasterMovement(controlled, f.actor, order, receipt));
}
const exhausted = structuredClone(f.game); exhausted.spice[f.pile] = 0;
rejected(exhausted, f.actor, f.action);
const movement = { ...order, player: f.actor, total: f.sourceCount, advisors: false, wantsFighters: false };
const flight = quoteDiscoveryFlight(f.game, f.token, movement);
validateDiscoveryFlight(f.game, flight, movement);
for (const state of ['removed', 'foreign', 'sameTurn'] as const) {
const controlled = structuredClone(f.game), token = controlled.discoveries!.tokens.find(token => token.id === f.token)!;
if (state === 'removed') { token.status = 'removed'; token.owner = null; }
else if (state === 'foreign') token.owner = f.observer;
else { token.acquiredTurn = controlled.turn; token.revealedTurn = controlled.turn; }
assert.throws(() => validateDiscoveryFlight(controlled, flight, movement));
}
const movedSource = structuredClone(f.game); player(movedSource, f.actor).forces[f.source]--;
player(movedSource, f.actor).tanks++;
rejected(movedSource, 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 an owned Discovery movement with legal real-pile Sandmaster collection', () => {
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 action = botActions(view).find(action => action.type === 'move' && action.discoveryOrnithopter === f.token &&
(action.sandmaster as SandmasterChoice | undefined)?.collect.length);
assert.ok(action, `${difficulty} offers an owned Discovery/Sandmaster route through actual positive spice`);
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);
}
}
});
+290
View File
@@ -0,0 +1,290 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { SandmasterMovement, sandmasterMoveDraft } from '../components/sandmaster-movement';
import { SandmasterMovementAction, type SandmasterMovementActionProps, type MovementFlight } from '../components/sandmaster-movement-action';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
import { planetologistFlightSelection as movementFlightSelection } from '../game/planetologist-flight';
import { gameTerritories, location, splitLocation } from '../game/board';
import { groundMoveRouteDistance } from '../game/bot-mobility';
import { richeseCards } from '../game/richese-cards';
import { createRicheseNoField, deployRicheseNoField, projectRicheseNoField, type NoFieldValue } from '../game/richese-no-field';
import { sandmasterActionOrder, sandmasterDefaultChoice, sandmasterOrderSources, sandmasterRouteDistance } from '../game/sandmaster-movement';
const source = 'false_wall_west:16', otherSource = 'false_wall_west:17';
const destination = 'wind_pass:16', firstPile = 'wind_pass:15', secondPile = 'wind_pass:16';
const ordinary: Action = { type: 'move', forces: { [source]: 2 }, territory: 'wind_pass', sector: 16 };
// Controlled personalized capabilities supplement the independent native flight
// and Richese marker sources. No trainer/card/token/army/receipt is installed in
// an authoritative Game. React renders the stateful route/pile parent; only the
// hook-free actual submit leaf is entered directly.
function projected(richese = false): GameView {
const sourceGame = createGame('SANDMASTERFLIGHTCONTROLS', newPlayer('p', 'Pilot', richese ? 'richese' : 'emperor'), false, ['choam']);
joinGame(sourceGame, newPlayer('a', 'Atreides', 'atreides'));
const game = viewGame(sourceGame, '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 = { [source]: 3, [otherSource]: 2 }; me.moved = 0; me.movesAllowed = 1; me.shipped = true;
game.spice = { [firstPile]: 4, [secondPile]: 3 };
game.leaderSkills = { offer: null, eligibleLeaders: [], battleChoice: null,
assignments: [{ skill: 'sandmaster', 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.discoveryEnabled = true;
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 }] };
game.discoveryOrnithopter = { token: 'discovery-token-1', acquiredTurn: 1, range: 3, blocked: null };
return game;
}
function active(game: GameView) {
const card = game.ornithopter!;
card.active = { event: 'controlled-sandmaster-flight', mode: 'twoGroups', completed: 1, remaining: 1,
cohort: { forces: { [otherSource]: 1 }, elites: {} } };
game.playedOrnithopter = { player: game.me, card: card.card, mode: 'twoGroups', completed: 1 };
game.players[0].moved = 1;
return game;
}
function marker(game: GameView, value: NoFieldValue) {
const tokens = createRicheseNoField(['sand-zero', 'sand-three', 'sand-five']);
const model = deployRicheseNoField(tokens, { tokenId: tokens.tokens.find(token => token.value === value)!.id,
controller: game.me, location: splitLocation(source) });
const publicMarker = projectRicheseNoField(model, false);
game.players[0].noField = publicMarker;
game.richeseNoField = { owner: game.me, event: 'controlled-sandmaster-marker', public: publicMarker, private: model } as NonNullable<GameView['richeseNoField']>;
return game;
}
function markerMove(game: GameView, physical = false): Action {
return { type: 'move', forces: physical ? { [source]: 2 } : {},
noField: game.richeseNoField!.private!.deployed!.tokenId, event: game.richeseNoField!.event,
territory: 'wind_pass', sector: 16 };
}
function propsFor(game: GameView, flight?: MovementFlight, move: Action = ordinary): SandmasterMovementActionProps {
return { game, move, flight, busy: false, reasonId: 'controlled-sandmaster-reason', act() {} };
}
function click(props: SandmasterMovementActionProps): Action[] {
const actions: Action[] = [];
SandmasterMovementAction({ ...props, act(action) { actions.push(action); } }).props.onClick();
return actions;
}
function markup(game: GameView, move: Action = ordinary, busy = false) {
return renderToStaticMarkup(createElement(SandmasterMovement, { game, move, busy, act() {} }));
}
function targetAtDistance(game: GameView, distance: number) {
const found = gameTerritories(game).flatMap(place => place.sectors.map(sector => location(place.id, sector)))
.find(to => groundMoveRouteDistance(game, game.players[0], source, to) === distance);
assert.ok(found, `A controlled canonical target at distance ${distance} exists`);
return splitLocation(found);
}
void test('existing four-argument drafts and actual ordinary callbacks preserve the exact move routes and pile choice', () => {
const game = projected(), before = structuredClone(game);
const draft = sandmasterMoveDraft(game, ordinary);
assert.equal(draft.blocked, null);
assert.deepEqual(draft.choice?.collect, [firstPile]);
assert.deepEqual(click(propsFor(game)), [draft.action]);
assert.deepEqual(draft.action, { ...ordinary, sandmaster: draft.choice });
assert.deepEqual(game, before);
});
void test('actual range3 two-group and Discovery callbacks carry only the offered flight source and original Sandmaster choice', () => {
for (const flight of ['range3', 'twoGroups', 'discovery'] as const) {
const game = flight === 'discovery' ? carried(projected()) : projected(), before = structuredClone(game);
const draft = sandmasterMoveDraft(game, ordinary, undefined, [], flight);
assert.equal(draft.blocked, null);
assert.deepEqual(click(propsFor(game, flight)), [{ ...ordinary,
...movementFlightSelection(game, flight).fields, sandmaster: draft.choice }]);
assert.deepEqual(click({ ...propsFor(game, flight), busy: true }), []);
assert.deepEqual(game, before);
}
});
void test('fixed three-territory card and Discovery routes retain the cap while ordinary and two-group flight keep normal range', () => {
const game = projected(), far = targetAtDistance(game, 3), beyond = targetAtDistance(game, 4);
const farMove = { ...ordinary, ...far };
for (const flight of ['ordinary', 'twoGroups'] as const) assert.deepEqual(click(propsFor(game, flight, farMove)), []);
for (const flight of ['range3', 'discovery'] as const) {
const view = flight === 'discovery' ? carried(projected()) : projected();
assert.equal(click(propsFor(view, flight, farMove)).length, 1);
assert.deepEqual(click(propsFor(view, flight, { ...ordinary, ...beyond })), []);
}
});
void test('the declared connected non-shortest route is submitted unchanged rather than replaced by a shortest route', () => {
const game = projected(), routes = { [source]: [source, 'wind_pass:17', 'polar_sink:0', destination] };
const defaults = sandmasterDefaultChoice(game, game.me, { ...ordinary, ...movementFlightSelection(game, 'range3').fields });
assert.ok(defaults); assert.notDeepEqual(defaults.routes, routes);
assert.equal(sandmasterRouteDistance(game, routes[source]), 3);
const actions = click({ ...propsFor(game, 'range3'), routes });
assert.deepEqual(actions, [{ ...ordinary, ...movementFlightSelection(game, 'range3').fields,
sandmaster: { routes, collect: [firstPile] } }]);
assert.deepEqual(click({ ...propsFor(game, 'range3'), routes: { [source]: [source, 'arrakeen:10', destination] } }), []);
});
void test('actual pile choices select one positive sector per entered territory or explicitly decline all peers', () => {
const game = projected(), routes = { [source]: [source, 'wind_pass:17', destination] };
for (const [declined, collect] of [[[], [firstPile]], [[firstPile], [secondPile]], [[firstPile, secondPile], []]] as const) {
assert.deepEqual(click({ ...propsFor(game, 'range3'), routes, declined }), [
{ ...ordinary, ...movementFlightSelection(game, 'range3').fields, sandmaster: { routes, collect: [...collect] } },
]);
}
const html = markup(game);
assert.match(html, /Collect 1 spice in Wind Pass, sector 15/);
assert.match(html, /Collect 1 spice in Wind Pass, sector 16/);
});
void test('active second groups use their current event and remaining original cohort after the ordinary allowance is spent', () => {
const game = active(projected()), move = { ...ordinary, forces: { [otherSource]: 1 } };
const draft = sandmasterMoveDraft(game, move);
assert.equal(draft.blocked, null);
assert.deepEqual(click(propsFor(game, undefined, move)), [draft.action]);
assert.equal(draft.action?.ornithopterEvent, 'controlled-sandmaster-flight');
assert.equal(draft.action?.movementCard, undefined);
assert.deepEqual(click(propsFor(game, undefined, ordinary)), []);
assert.deepEqual(click(propsFor(game, 'discovery', move)), []);
const html = markup(game, move);
assert.match(html, /1 card movements completed · 1 remaining/);
});
void test('second-group quotas distinguish current physical normal and elite counters from the unmoved cohort', () => {
const game = active(projected());
game.players[0].elites = { forces: { [otherSource]: 1 }, reserves: 0, tanks: 0, revived: 0 };
const move = { ...ordinary, forces: { [otherSource]: 1 }, eliteForces: { [otherSource]: 1 } };
assert.deepEqual(click(propsFor(game, undefined, move)), []);
assert.equal(click(propsFor(game, undefined, { ...move, eliteForces: { [otherSource]: 0 } })).length, 1);
assert.deepEqual(click(propsFor(game, undefined, { ...move, forces: { [otherSource]: 2 } })), []);
});
void test('marker-only zero three and five use one moving source without reading or materializing the denomination', () => {
for (const value of [0, 3, 5] as const) for (const flight of ['ordinary', 'range3', 'twoGroups', 'discovery'] as const) {
const game = marker(flight === 'discovery' ? carried(projected(true)) : projected(true), value);
const move = markerMove(game), before = structuredClone(game);
const draft = sandmasterMoveDraft(game, move, undefined, [], flight);
assert.equal(draft.blocked, null);
assert.deepEqual(Object.keys(draft.choice!.routes), [source]);
assert.deepEqual(draft.choice!.collect, [firstPile]);
assert.deepEqual(click(propsFor(game, flight, move)), [draft.action]);
assert.deepEqual(game, before);
const html = markup(game, move);
assert.match(html, /aria-label="Provisional Sandmaster concealed marker"/);
assert.match(html, /including zero/);
assert.doesNotMatch(html, /sand-zero|sand-three|sand-five/);
}
});
void test('a same-sector marker and physical group share one route and collect once for the entered territory', () => {
const game = marker(projected(true), 5), move = markerMove(game, true);
const draft = sandmasterMoveDraft(game, move, undefined, [], 'range3');
const order = sandmasterActionOrder(game, game.me, draft.action!);
assert.ok(order);
assert.deepEqual(sandmasterOrderSources(order), [source]);
assert.deepEqual(Object.keys(draft.choice!.routes), [source]);
assert.deepEqual(draft.choice!.collect, [firstPile]);
assert.deepEqual(click(propsFor(game, 'range3', move)), [draft.action]);
assert.equal((markup(game, move).match(/<legend>False Wall West, sector 16 route<\/legend>/g) ?? []).length, 1);
const separate = { ...markerMove(game), forces: { [otherSource]: 1 } };
const twoRoutes = sandmasterMoveDraft(game, separate, undefined, [], 'range3');
assert.equal(Object.keys(twoRoutes.choice!.routes).length, 2);
assert.deepEqual(twoRoutes.choice!.collect, [firstPile]);
assert.deepEqual(click(propsFor(game, 'range3', separate)), [twoRoutes.action]);
});
void test('a marker in the active frame must remain an original unmoved cohort member', () => {
const game = active(marker(projected(true), 0)), move = markerMove(game);
assert.deepEqual(click(propsFor(game, undefined, move)), []);
game.ornithopter!.active!.cohort!.noField = { tokenId: 'sand-zero', event: game.richeseNoField!.event, from: source };
assert.equal(click(propsFor(game, undefined, move)).length, 1);
game.ornithopter!.active!.cohort!.noField!.event = 'old-marker-event';
assert.deepEqual(click(propsFor(game, undefined, move)), []);
});
void test('stale card token active event selected source trainer or selected pile invalidates an already-rendered callback', () => {
const checks: { flight: MovementFlight; change: (game: GameView) => void }[] = [
{ flight: 'range3', change: game => { game.ornithopter!.card = { ...game.ornithopter!.card, id: 'different-card' }; } },
{ flight: 'discovery', change: game => { game.discoveries!.tokens[0].id = 'discovery-token-2'; game.discoveryOrnithopter!.token = 'discovery-token-2'; } },
{ flight: 'range3', change: game => { game.players[0].forces[source] = 0; } },
{ flight: 'range3', change: game => { game.leaderSkills!.assignments[0].leader = game.players[0].leaders[1].id; } },
{ flight: 'range3', change: game => { game.spice[firstPile] = 2; } },
{ flight: 'range3', change: game => { game.spice[firstPile] = 0; } },
];
for (const { flight, change } of checks) {
const game = flight === 'discovery' ? carried(projected()) : projected(), actions: Action[] = [];
const leaf = SandmasterMovementAction({ ...propsFor(game, flight), act(action) { actions.push(action); } });
assert.equal(leaf.props.disabled, false);
change(game); leaf.props.onClick(); assert.deepEqual(actions, []);
}
const game = active(projected()), actions: Action[] = [];
const leaf = SandmasterMovementAction({ ...propsFor(game, undefined, { ...ordinary, forces: { [otherSource]: 1 } }), act(action) { actions.push(action); } });
game.ornithopter!.active!.event = 'changed-flight-event'; leaf.props.onClick(); assert.deepEqual(actions, []);
});
void test('foreign marker ownership current marker event and current token custody guard actual callbacks', () => {
for (const change of [
(game: GameView) => { game.richeseNoField!.owner = 'a'; },
(game: GameView) => { game.richeseNoField!.event = 'new-marker-event'; },
(game: GameView) => { game.richeseNoField!.private!.deployed = null; },
(game: GameView) => { game.richeseNoField!.private!.deployed!.controller = 'a'; },
]) {
const game = marker(projected(true), 0), move = markerMove(game); change(game);
assert.deepEqual(click(propsFor(game, 'range3', move)), []);
}
const game = marker(projected(true), 0), actions: Action[] = [];
const leaf = SandmasterMovementAction({ ...propsFor(game, 'range3', markerMove(game)),
act(action) { actions.push(action); } });
assert.equal(leaf.props.disabled, false);
game.richeseNoField!.event = 'changed-marker-event';
leaf.props.onClick(); assert.deepEqual(actions, []);
});
void test('empty optional notices cannot bypass current ownership phase response automatic trainer or allowance guards', () => {
const changes: ((game: GameView) => void)[] = [
game => { game.phase = 6; }, game => { game.active = 'a'; },
game => { game.response = {} as NonNullable<GameView['response']>; },
game => { game.decision = { kind: 'mobileStronghold', player: 'a', placement: false }; },
game => { game.truthtrance = {} as NonNullable<GameView['truthtrance']>; },
game => { game.automaticContinuationPending = true; }, game => { game.players[0].autopilot = 'Easy'; },
game => { game.players[0].moved = 1; }, game => { game.players[0].leaders[0].dead = true; },
game => { game.me = 'a'; },
];
for (const change of changes) {
const game = projected(); change(game);
assert.deepEqual(click({ ...propsFor(game, 'range3'), unavailableReason: '' }), []);
}
const game = projected();
assert.deepEqual(click({ ...propsFor(game, 'range3'), busy: true }), []);
assert.deepEqual(click({ ...propsFor(game, 'range3'), unavailableReason: 'Finish the promised shipment first.' }), []);
assert.deepEqual(click(propsFor(game, 'range3', { ...ordinary, planetologist: 'range' })), []);
});
void test('the form retains all transport choices when the ordinary route is too long and explains provisional exact-route collection', () => {
const game = carried(projected()), far = { ...ordinary, ...targetAtDistance(game, 3) };
const html = markup(game, far);
assert.match(html, /Sandmaster movement/);
assert.match(html, /Movement transport/);
for (const choice of ['ordinary', 'range3', 'twoGroups', 'discovery']) assert.match(html, new RegExp(`<option value="${choice}"`));
assert.match(html, /aria-label="Provisional Sandmaster flight composition"/);
assert.match(html, /fixed-range card and Discovery flight allow routes up to three territories/);
assert.match(html, /Each two-group card movement keeps normal range and uses different original forces/);
assert.match(html, /Only one flight source may be used; Planetologist remains separate/);
assert.match(markup(projected()), /Provisional multi-pile policy/);
});
void test('marker routes and actual callbacks never read hidden denominations raw marker events or rival private inventories', () => {
const game = marker(projected(true), 0), move = markerMove(game);
for (const token of game.richeseNoField!.private!.tokens)
Object.defineProperty(token, 'value', { get() { throw Error('Sandmaster controls read hidden denomination'); } });
for (const player of game.players) for (const field of ['hand', 'traitors', 'faceDancers', 'spice', 'reserves', 'noFieldEvent'])
Object.defineProperty(player, field, { get() { throw Error(`Sandmaster controls read unrelated ${player.id}.${field}`); } });
assert.equal(click(propsFor(game, 'range3', move)).length, 1);
assert.match(markup(game, move), /Provisional Sandmaster concealed marker/);
const rival = marker(projected(true), 3), rivalMove = markerMove(rival); rival.richeseNoField!.owner = 'a';
Object.defineProperty(rival.richeseNoField!, 'private', { get() { throw Error('Sandmaster controls read foreign marker identity'); } });
assert.deepEqual(click(propsFor(rival, 'range3', rivalMove)), []);
});
+43
View File
@@ -0,0 +1,43 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action } from '../game/engine';
import { presenceAt } from '../game/force-presence';
import { splitLocation } from '../game/board';
import { sandmasterActionOrder, sandmasterOrderSources, quoteSandmasterMovement, validateSandmasterMovement } from '../game/sandmaster-movement';
import { createSandmasterMarkerFixture } from './fixture-sandmaster-marker';
for (const advanced of [false, true]) for (const value of [0, 3, 5] as const) for (const physical of [false, true]) {
void test(`controlled ${advanced ? 'Advanced' : 'Basic'} Sandmaster marker${value} ${physical ? 'with physical companions' : 'alone'} collects without materialization`, () => {
const f = createSandmasterMarkerFixture(value, physical, advanced), before = structuredClone(f.game);
const order = sandmasterActionOrder(viewGame(f.game, f.actor), f.actor, f.action); assert.ok(order);
assert.deepEqual(sandmasterOrderSources(order), [f.source]);
assert.equal(order.group.length, physical ? 1 : 0); assert.equal(order.noField!.tokenId, f.token.id);
const next = applyAction(f.game, f.actor, f.action), owner = next.players.find(p => p.id === f.actor)!;
assert.deepEqual(f.game, before);
assert.equal(owner.reserves, 16); assert.equal(owner.tanks, 0); assert.equal(owner.moved, 1);
assert.equal(owner.forces[f.source] ?? 0, physical ? 0 : 3);
assert.equal(owner.forces[f.target] ?? 0, physical ? 3 : 0);
assert.equal(presenceAt(owner, splitLocation(f.target).territory), physical ? 4 : 1);
assert.deepEqual(owner.noField!.tokens, f.originalNoField.tokens);
assert.equal(owner.noField!.lastShipped, f.originalNoField.lastShipped);
assert.deepEqual(owner.noField!.deployed!.location, splitLocation(f.target));
assert.notEqual(owner.noFieldEvent, f.originalEvent); assert.equal(owner.spice, f.originalWallet + 2);
for (const pile of f.choice.collect) assert.equal(next.spice[pile], f.originalSpice[pile] - 1);
const unchanged = structuredClone(next);
assert.throws(() => applyAction(next, f.actor, f.action)); assert.deepEqual(next, unchanged);
});
}
void test('marker route receipts bind current source and event while stale and foreign declarations reject immutably', () => {
const f = createSandmasterMarkerFixture(), view = viewGame(f.game, f.actor);
const order = sandmasterActionOrder(view, f.actor, f.action); assert.ok(order);
const receipt = quoteSandmasterMovement(f.game, f.actor, order, f.choice);
assert.doesNotThrow(() => validateSandmasterMovement(f.game, f.actor, order, receipt));
assert.throws(() => validateSandmasterMovement(f.game, f.actor,
{ ...order, noField: { ...order.noField!, event: 'changed-event' } }, receipt));
const before = structuredClone(f.game);
for (const [actor, action] of [[f.actor, { ...f.action, event: 'stale-event' }],
[f.actor, { ...f.action, noField: 'unowned-marker' }], ['h', f.action]] as [string, Action][]) {
assert.throws(() => applyAction(f.game, actor, action)); assert.deepEqual(f.game, before);
}
});
+202
View File
@@ -0,0 +1,202 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { splitLocation } from '../game/board';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
quoteSandmasterMovement, sandmasterActionOrder, sandmasterCollectionPiles,
sandmasterLeader, sandmasterRouteDistance, sandmasterShortestRoute, type SandmasterChoice,
} from '../game/sandmaster-movement';
import {
actSandmasterOrnithopter as act, advanceSandmasterOrnithopter as advance,
createSandmasterOrnithopterFixture as fixture, finishSandmasterOrnithopter as finish,
moveSandmasterOrnithopter as move, sandmasterOrnithopterClean as clean,
sandmasterOrnithopterDestination as destination, sandmasterOrnithopterInventory as inventory,
sandmasterOrnithopterPlayer as player, sandmasterOrnithopterSecondAction as secondAction,
sandmasterOrnithopterStocks as stocks, type SandmasterOrnithopterFixture,
} from './fixture-sandmaster-ornithopter';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
}
const ownView = (game: Game, actor: string) => viewGame(game, actor).players.find(seat => seat.id === actor)!;
const bonusLogs = (game: Game) => game.log.filter(entry => entry.text.includes('Sandmaster collected 1 spice while moving')).length;
function withBonus(f: SandmasterOrnithopterFixture, game: Game, bonus: number) {
const expected = structuredClone(f.originalStocks);
expected.find(seat => seat.id === f.actor)!.spice += bonus;
assert.deepEqual(stocks(game), expected);
assert.equal(bonusLogs(game) - f.originalSandmasterLogCount, bonus);
assert.equal(game.spice[f.pile] ?? 0, f.pileBefore - bonus);
for (const [key, amount] of Object.entries(f.originalPiles))
if (key !== f.pile) assert.equal(game.spice[key], amount, 'Unselected entered piles are not additional compulsory income');
assert.deepEqual(inventory(game), f.originalInventory);
}
function source(f: SandmasterOrnithopterFixture) {
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(sandmasterLeader(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 === 'sandmaster' && 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.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.deepEqual(player(f.game, f.actor).forces, { [f.source]: 4 });
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(f.game.ornithopter ?? null, null);
assert.equal(f.beforeBlow.turn, 2); assert.equal(f.beforeBlow.phase, 0);
assert.deepEqual(f.spiceOrder.after.map(card => JSON.stringify(card)).sort(), f.spiceOrder.original.map(card => JSON.stringify(card)).sort());
assert.ok(f.blow.steps.some(step => step.action.type === 'ready' || step.action.type === 'stormDial'));
assert.ok(f.blow.after.spiceDiscard.flat().some(card => JSON.stringify(card) === JSON.stringify(f.blow.card)));
assert.equal(f.blow.after.spice[f.pile], (f.beforeBlow.spice[f.pile] ?? 0) + f.blow.card.amount);
assert.equal(f.game.spice[f.pile], f.pileBefore); assert.ok(f.pileBefore >= 2);
assert.equal(f.choice.routes[f.source][0], f.source);
assert.equal(f.choice.routes[f.source].at(-1), f.targetLocation);
assert.deepEqual(f.choice.collect, [f.pile]);
assert.equal(sandmasterRouteDistance(f.game, f.route), f.mode === 'range3' ? 3 : 1);
const view = viewGame(f.game, f.actor), order = sandmasterActionOrder(view, f.actor, f.action);
assert.ok(order, 'The real held-card action has an owned Sandmaster order');
assert.equal(order.range, f.mode === 'range3' ? 3 : 1);
const quote = quoteSandmasterMovement(f.game, f.actor, order, f.choice);
assert.deepEqual(quote.routes, f.choice.routes);
assert.deepEqual(quote.piles, [{ key: f.pile, before: f.pileBefore }]);
if (f.mode === 'range3') {
const shortest = sandmasterShortestRoute(f.game, f.actor, f.source, f.targetLocation, 3); assert.ok(shortest);
assert.ok(sandmasterRouteDistance(f.game, shortest) < 3,
'The owned original choice deliberately retains a longer legal collecting route');
}
assert.equal(f.sourceActionCount, f.actions.length); assert.deepEqual(inventory(f.game), f.originalInventory);
}
function retired(f: SandmasterOrnithopterFixture, game: Game, movements: 1 | 2, bonus: number) {
withBonus(f, game, bonus);
assert.equal(player(game, f.actor).moved, movements);
assert.equal(ownView(game, f.actor).movesAllowed, movements);
assert.equal(player(game, f.actor).ornithopterExtraMoves ?? 0, movements - 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.some(card => card.id === f.card), false);
}
for (const advanced of [false, true]) for (const mode of ['range3', 'twoGroups'] as const) {
void test(`public ${advanced ? 'Advanced' : 'Basic'} Sandmaster ${mode} collects from actual Spice blows once per accepted physical movement`, () => {
const f = fixture({ advanced, mode }); source(f);
const before = structuredClone(f.game);
let game = move(f); assert.deepEqual(f.game, before);
withBonus(f, game, 1);
assert.equal(player(game, f.actor).forces[f.source], 2);
assert.equal(player(game, f.actor).forces[f.targetLocation], 2);
assert.equal(player(game, f.actor).moved, 1);
if (mode === 'twoGroups') {
assert.equal(game.ornithopter?.completed, 1);
assert.deepEqual(game.ornithopter.cohort!.forces, { [f.source]: 2 });
assert.equal(game.discard.some(card => card.id === f.card), false);
assert.equal(player(game, f.actor).hand.some(card => card.id === f.card), false);
rejected(game, f.actor, { type: 'move', forces: { [f.targetLocation]: 1 },
ornithopterEvent: game.ornithopter.event, ...f.sourceLocation });
const action = secondAction(f, game), order = sandmasterActionOrder(viewGame(game, f.actor), f.actor, action);
assert.ok(order);
const quote = quoteSandmasterMovement(game, f.actor, order, action.sandmaster);
assert.deepEqual(quote.piles, [{ key: f.pile, before: f.pileBefore - 1 }], 'The second original cohort requotes the actual remaining spice');
game = act(game, f.actor, action); game = advance(game, clean);
assert.deepEqual(player(game, f.actor).forces, { [f.targetLocation]: 4 });
retired(f, game, 2, 2);
} else retired(f, game, 1, 1);
rejected(game, f.actor, f.action);
const bonus = bonusLogs(game), forces = structuredClone(player(game, f.actor).forces);
const 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.equal(bonusLogs(game), bonus, 'Ordinary Collection does not repeat any movement bonus');
assert.deepEqual(player(game, f.actor).forces, forces);
assert.equal(player(game, f.actor).reserves, 16); assert.equal(player(game, f.actor).tanks, 0);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
assert.deepEqual(inventory(game), f.originalInventory);
});
}
void test('each actual card movement can independently decline Sandmaster without altering its original force route or disposal', () => {
for (const advanced of [false, true]) for (const firstCollect of [false, true]) {
const f = fixture({ advanced, mode: 'twoGroups' }); source(f);
let game = move(f, structuredClone(f.game), [], firstCollect);
withBonus(f, game, Number(firstCollect));
game = act(game, f.actor, secondAction(f, game, !firstCollect)); game = advance(game, clean);
retired(f, game, 2, 1);
assert.deepEqual(player(game, f.actor).forces, { [f.pile]: 4 });
}
const f = fixture({ mode: 'range3' }); source(f);
const game = move(f, structuredClone(f.game), [], false);
retired(f, game, 1, 0);
assert.equal(player(game, f.actor).forces[f.targetLocation], 2);
});
void test('invalid physical-flight Sandmaster routes and pile choices reject before card, board spice or original forces move', () => {
const f = fixture({ mode: 'range3' }); source(f);
rejected(f.game, f.observer, f.action);
rejected(f.game, f.actor, { ...f.action, sandmaster: { routes: {}, collect: [f.pile] } });
rejected(f.game, f.actor, { ...f.action, sandmaster: { routes: f.routes, collect: [f.pile, f.pile] } });
rejected(f.game, f.actor, { ...f.action, sandmaster: { routes: f.routes, collect: [f.source] } });
rejected(f.game, f.actor, { ...f.action, sandmaster: { routes: { [f.source]: [f.source, f.targetLocation] }, collect: [f.pile] } });
rejected(f.game, f.actor, { ...f.action, ornithopterEvent: 'stale-card-flight' });
const changed = structuredClone(f.game); changed.spice[f.pile] = 0;
rejected(changed, f.actor, f.action); // Labelled changed-pile supplement; not a played source.
assert.deepEqual(stocks(f.game), f.originalStocks);
assert.deepEqual(f.game.spice, f.originalPiles);
assert.ok(player(f.game, f.actor).hand.some(card => card.id === f.card));
assert.deepEqual(inventory(f.game), f.originalInventory);
});
void test('early ending retains one committed Sandmaster payment and discards the abandoned second flight once', () => {
const f = fixture({ mode: 'twoGroups' }); source(f);
let game = move(f);
game = act(game, f.actor, f.endMovement); game = advance(game, state => clean(state) && state.active !== f.actor);
assert.equal(bonusLogs(game) - f.originalSandmasterLogCount, 1);
assert.equal(player(game, f.actor).ornithopterExtraMoves ?? 0, 0);
assert.equal(player(game, f.actor).moved, 1);
assert.equal(player(game, f.actor).forces[f.source], 2);
assert.equal(player(game, f.actor).forces[f.pile], 2);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
const bonus = bonusLogs(game); game = finish(f, game);
assert.equal(game.phase, 7); assert.equal(bonusLogs(game), bonus);
assert.deepEqual(inventory(game), f.originalInventory);
});
void test('all four minimal profiles offer a collected original physical flight and its remaining original group', () => {
for (const advanced of [false, true]) for (const mode of ['range3', 'twoGroups'] as const) {
const f = fixture({ advanced, mode }); 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 => {
if (action.type !== 'move' || !action.sandmaster || typeof action.sandmaster !== 'object' || Array.isArray(action.sandmaster)) return false;
const choice = action.sandmaster as Partial<SandmasterChoice>;
return Array.isArray(choice.collect) && choice.collect.length > 0 &&
(game.ornithopter ? action.ornithopterEvent === game.ornithopter.event
: action.movementCard === f.card && action.ornithopter === mode);
});
assert.ok(candidates.length, `${difficulty} offers original ${mode}/${game.ornithopter ? 'second' : 'first'} Sandmaster collection`);
const action = candidates[0], choice = action.sandmaster as SandmasterChoice;
const offered = sandmasterCollectionPiles(game, choice.routes);
assert.ok(choice.collect.every(key => offered[splitLocation(key).territory]?.includes(key)));
const before = player(game, f.actor), moved = before.moved;
let accepted = act(game, f.actor, action); accepted = advance(accepted, clean);
assert.equal(player(accepted, f.actor).moved, moved + 1);
assert.equal(player(accepted, f.actor).spice, before.spice + choice.collect.length);
assert.equal(player(accepted, f.actor).reserves, before.reserves); assert.equal(player(accepted, f.actor).tanks, before.tanks);
for (const key of choice.collect) assert.equal(accepted.spice[key] ?? 0, game.spice[key] - 1);
assert.equal(typeof destination(action), 'string');
assert.deepEqual(inventory(accepted), f.originalInventory);
}
}
});