Enable Sandmaster collection on flight and marker routes
This commit is contained in:
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);
|
||||
}
|
||||
@@ -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.' };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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)), []);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user