Files
Dune/tests/sandmaster-movement.test.ts
T

408 lines
16 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import {
applyAction,
normalizeAutomaticGame,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import {
sandmasterDefaultChoice,
type SandmasterChoice,
} from '../game/sandmaster-movement';
import { splitLocation, territory, mobileRoutes, mobileRouteDistance, MOBILE_LOCATION as INSIDE } from '../game/board';
import {
sandmasterMove,
sandmasterMovementGame,
} from './sandmaster-movement-fixture';
import { placeFixtureHand } from './fixture-hand';
import { smugglerShipmentGame } from './smuggler-shipment-fixture';
const restored = (g: Game): Game => JSON.parse(JSON.stringify(g));
function unchanged(
g: Game,
action: Action,
) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, 'p', action));
assert.deepEqual(g, before);
}
function conserved(g: Game, before: Game) {
for (const p of g.players)
assert.equal(
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
20,
);
const sum = (game: Game) =>
Object.values(game.spice).reduce((a, b) => a + b, 0) +
game.players.reduce((a, p) => a + p.spice, 0);
assert.equal(sum(g), sum(before));
}
function strongholdPileFixture(advanced = false) {
let game = smugglerShipmentGame('ixians', advanced, 'sandmaster', undefined, ['ix']);
game.players[0].forces = { [INSIDE]: 6 };
game.players[0].reserves = 14;
if (game.players[0].elites) game.players[0].elites.forces = { [INSIDE]: 3 };
game.mobileStronghold = { location: 'polar_sink:0' };
game.spice = {};
game.phase = 8;
game.active = null;
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
assert.equal(game.decision?.kind, 'mobileStronghold');
const route = mobileRoutes(game, 1).find(keys => mobileRouteDistance(keys) === 1 &&
territory(splitLocation(keys.at(-1)!).territory).sectors.some(
sector => sector !== splitLocation(keys.at(-1)!).sector && sector !== game.storm))!;
assert.ok(route);
const landing = route.at(-1)!;
const at = splitLocation(landing);
const other = `${at.territory}:${territory(at.territory).sectors.find(
sector => sector !== at.sector && sector !== game.storm)!}`;
game.spice = { [landing]: 12, [other]: 5 };
const karama = game.deck.find(card => card.effect === 'karama')!;
assert.ok(karama);
game.deck = game.deck.filter(card => card.id !== karama.id);
game.players[1].hand.push(karama);
return { game, route, landing, other, karama };
}
function allowStrongholdMove(state: Game): Game {
let game = state;
while (game.response) {
const owner = game.players.find(player => !game.response!.passed.includes(player.id))!.id;
game = applyAction(game, owner, { type: 'passResponse' });
}
return game;
}
void test('stronghold Sandmaster sector selection precedes native collection and cancellation transfers nothing', () => {
for (const advanced of [false, true]) {
const { game, route, landing, other, karama } = strongholdPileFixture(advanced);
unchanged(game, { type: 'decision', route });
unchanged(game, { type: 'decision', route, sandmasterPiles: [landing, other] });
for (const selected of [landing, other]) {
const pending = applyAction(game, 'p', { type: 'decision', route, sandmasterPiles: [selected] });
assert.equal(pending.players[0].spice, game.players[0].spice);
const done = allowStrongholdMove(restored(pending));
assert.equal(done.mobileStronghold!.location, landing);
assert.equal(done.spice[landing] ?? 0, 0);
assert.equal(done.spice[other], selected === other ? 4 : 5);
assert.equal(done.players[0].spice - game.players[0].spice, selected === other ? 13 : 12);
}
const pending = applyAction(game, 'p', { type: 'decision', route, sandmasterPiles: [other] });
const canceled = applyAction(pending, game.players[1].id, { type: 'card', card: karama.id, mode: 'cancel' });
assert.deepEqual(canceled.spice, game.spice);
assert.equal(canceled.mobileStronghold!.location, 'polar_sink:0');
assert.equal(canceled.players[0].spice, game.players[0].spice);
}
});
void test('Ixian special Karama retains selected Sandmaster pile without consuming ordinary transport', () => {
const { game, route, landing, other, karama } = strongholdPileFixture(true);
// Conserved held-card transfer and labelled phase-five budget seam.
game.phase = 5;
game.active = 'p';
game.decision = null;
game.response = null;
game.players[1].hand = game.players[1].hand.filter(card => card.id !== karama.id);
game.players[0].hand.push(karama);
const done = applyAction(game, 'p',
{ type: 'card', mode: 'special', card: karama.id, route, sandmasterPiles: [other] });
assert.equal(done.mobileStronghold!.location, landing);
assert.equal(done.spice[landing] ?? 0, 0);
assert.equal(done.spice[other], 4);
assert.equal(done.players[0].spice - game.players[0].spice, 13);
assert.equal(done.players[0].moved, game.players[0].moved);
assert.equal(done.players[0].shipped, game.players[0].shipped);
assert.equal(done.players[0].specialKaramaUsed, true);
assert.equal(done.discard.filter(card => card.id === karama.id).length, 1);
});
void test('Sandmaster collects once in every selected traversed territory and preserves JSON continuation', () => {
for (const advanced of [false, true]) {
const g = sandmasterMovementGame('emperor', advanced),
action = sandmasterMove(g, 3);
const choice = action.sandmaster as SandmasterChoice;
assert.equal(choice.collect.length, 3);
g.spice['red_chasm:7'] = 6;
const next = applyAction(restored(g), 'p', action);
assert.equal(next.players[0].spice, g.players[0].spice + 3);
assert.equal(next.spice['red_chasm:7'], 6);
assert.equal(next.players[0].moved, 1);
assert.equal(
next.log.filter((l) => l.automatic?.name === 'Sandmaster collection')
.length,
3,
);
assert.deepEqual(
viewGame(restored(next), 'p').players[0].hand,
viewGame(next, 'p').players[0].hand,
);
assert.equal(
viewGame(next, 'h').players.find((p) => p.id === 'p')!.spice,
undefined,
);
conserved(next, g);
}
});
void test('Sandmaster can decline each territory or the entire effect; legacy movement collects nothing', () => {
const g = sandmasterMovementGame(),
action = sandmasterMove(g);
const choice = action.sandmaster as SandmasterChoice;
for (const collect of [[], choice.collect.slice(0, 1)]) {
const next = applyAction(g, 'p', {
...action,
sandmaster: { ...choice, collect },
});
assert.equal(next.players[0].spice, g.players[0].spice + collect.length);
conserved(next, g);
}
const { sandmaster: _unused, ...legacy } = action;
assert.equal(
applyAction(g, 'p', legacy).players[0].spice,
g.players[0].spice,
);
});
void test('Sandmaster chooses either positive sector pile but never collects twice from one entered territory', () => {
for (const advanced of [false, true]) {
const game = sandmasterMovementGame('emperor', advanced);
const action = sandmasterMove(game);
const choice = action.sandmaster as SandmasterChoice;
const original = choice.collect.find(key => territory(splitLocation(key).territory).sectors.some(
sector => sector !== splitLocation(key).sector && sector !== game.storm));
assert.ok(original);
const at = splitLocation(original);
const sector = territory(at.territory).sectors.find(value => value !== at.sector && value !== game.storm)!;
const alternate = `${at.territory}:${sector}`;
game.spice[alternate] = 2;
unchanged(game, { ...action, sandmaster: { ...choice, collect: [...choice.collect, alternate] } });
const collect = choice.collect.map(key => key === original ? alternate : key);
const done = applyAction(restored(game), 'p', { ...action, sandmaster: { ...choice, collect } });
assert.equal(done.spice[original], game.spice[original]);
assert.equal(done.spice[alternate], 1);
assert.equal(done.players[0].spice, game.players[0].spice + collect.length);
assert.equal(done.players[0].moved, 1);
const defaults = sandmasterDefaultChoice(viewGame(game, 'p'), 'p', action)!;
assert.equal(new Set(defaults.collect.map(key => splitLocation(key).territory)).size, defaults.collect.length);
conserved(done, game);
}
});
void test('Sandmaster rejects disconnected, overlong, storm and duplicate collection choices without mutation', () => {
const g = sandmasterMovementGame(),
action = sandmasterMove(g),
choice = action.sandmaster as SandmasterChoice;
const from = 'red_chasm:7',
route = choice.routes[from];
unchanged(g, { ...action, sandmaster: { ...choice, routes: {} } });
unchanged(g, {
...action,
sandmaster: {
...choice,
routes: { [from]: [from, 'polar_sink:0', route.at(-1)] },
},
});
unchanged(g, {
...action,
sandmaster: { ...choice, collect: [choice.collect[0], choice.collect[0]] },
});
unchanged(g, {
...action,
sandmaster: { ...choice, collect: ['red_chasm:7'] },
});
unchanged(g, { ...action, sandmaster: null });
const blocked = restored(g);
blocked.storm = splitLocation(route.at(-1)!).sector;
unchanged(blocked, action);
const tooFar = restored(g);
delete tooFar.players[0].forces['arrakeen:10'];
tooFar.players[0].reserves++;
unchanged(tooFar, action);
});
void test('Fremen Sandmaster waits for movement permission and cancellation releases untouched spice', () => {
const g = sandmasterMovementGame('fremen'),
action = sandmasterMove(g);
const karama = [...g.deck, ...g.players.flatMap((p) => p.hand)].find(
(c) => c.effect === 'karama',
)!;
placeFixtureHand(g, 1, [karama]);
const pending = applyAction(g, 'p', action);
assert.equal(pending.response?.kind, 'fremenMovement');
assert.equal(pending.players[0].spice, g.players[0].spice);
assert.deepEqual(pending.spice, g.spice);
const allowed = applyAction(restored(pending), 'h', { type: 'passResponse' });
assert.equal(allowed.players[0].spice, g.players[0].spice + 2);
conserved(allowed, g);
const canceled = applyAction(restored(pending), 'h', {
type: 'card',
mode: 'cancel',
card: karama.id,
});
assert.equal(canceled.players[0].spice, g.players[0].spice);
assert.deepEqual(canceled.spice, g.spice);
assert.equal(canceled.players[0].moved, 0);
for (const mutate of [
(saved: Game) => {
delete saved.pendingFremenMove!.order.sandmaster;
},
(saved: Game) => {
saved.response = null;
},
(saved: Game) => {
saved.pendingFremenMove!.order.sandmaster!.piles[0].before++;
},
(saved: Game) => {
saved.spice[(action.sandmaster as SandmasterChoice).collect[0]]++;
},
]) {
const saved = restored(pending);
mutate(saved);
assert.throws(() => viewGame(saved, 'p'), /Sandmaster/i);
assert.throws(() => normalizeAutomaticGame(saved), /Sandmaster/i);
}
});
void test('All four AI profiles can submit legal funded Sandmaster movement with only public route information', () => {
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const g = sandmasterMovementGame();
sandmasterMove(g);
g.players[0].autopilot = difficulty;
const view = viewGame(g, 'p');
const action = botActions(view).find(
(a) => a.type === 'move' && a.sandmaster,
);
assert.ok(action, difficulty);
const next = applyAction(g, 'p', action);
assert.ok(next.players[0].spice > g.players[0].spice);
conserved(next, g);
}
});
void test('Dead or captive Sandmaster cannot grant normal collection', () => {
const g = sandmasterMovementGame(),
action = sandmasterMove(g);
const skill = g.leaderSkills!.assignments.find((a) => a.owner === 'p')!;
const captive = restored(g);
captive.players[0].leaders.find((l) => l.id === skill.leader)!.capturedBy =
'h';
unchanged(captive, action);
});
void test('Sandmaster combines source sectors without duplicate collection and sector-only repositioning earns nothing', () => {
const g = sandmasterMovementGame();
g.players[0].forces = {
'pasty_mesa:6': 2,
'pasty_mesa:7': 1,
'arrakeen:10': 1,
};
g.spice = { 'red_chasm:7': 4, 'pasty_mesa:8': 5 };
const action: Action = {
type: 'move',
forces: { 'pasty_mesa:6': 2, 'pasty_mesa:7': 1 },
territory: 'red_chasm',
sector: 7,
};
const choice = sandmasterDefaultChoice(viewGame(g, 'p'), 'p', action)!;
assert.equal(Object.keys(choice.routes).length, 2);
assert.deepEqual(choice.collect, ['red_chasm:7']);
const next = applyAction(g, 'p', { ...action, sandmaster: choice });
assert.equal(next.players[0].spice, g.players[0].spice + 1);
assert.equal(next.players[0].forces['red_chasm:7'], 3);
assert.equal(next.spice['pasty_mesa:8'], 5);
conserved(next, g);
const reposition: Action = {
type: 'move',
from: 'pasty_mesa:6',
amount: 2,
territory: 'pasty_mesa',
sector: 7,
};
const sectorChoice = sandmasterDefaultChoice(
viewGame(g, 'p'),
'p',
reposition,
)!;
assert.deepEqual(sectorChoice.collect, []);
assert.equal(
applyAction(g, 'p', { ...reposition, sandmaster: sectorChoice }).players[0]
.spice,
g.players[0].spice,
);
});
void test('A separate Hajr movement can collect again and only a later re-entry qualifies its starting territory', () => {
const g = sandmasterMovementGame();
const hajr = g.deck.find((c) => c.effect === 'hajr')!;
assert.ok(hajr);
placeFixtureHand(g, 0, [hajr]);
g.spice = { 'pasty_mesa:8': 4, 'red_chasm:7': 5 };
const first: Action = {
type: 'move',
from: 'red_chasm:7',
amount: 3,
territory: 'pasty_mesa',
sector: 6,
};
let next = applyAction(g, 'p', {
...first,
sandmaster: sandmasterDefaultChoice(viewGame(g, 'p'), 'p', first),
});
assert.equal(next.spice['pasty_mesa:8'], 3);
next = applyAction(next, 'p', { type: 'card', card: hajr.id });
const second: Action = {
type: 'move',
from: 'pasty_mesa:6',
amount: 3,
territory: 'pasty_mesa',
sector: 7,
sandmaster: {
routes: {
'pasty_mesa:6': ['pasty_mesa:6', 'red_chasm:7', 'pasty_mesa:7'],
},
collect: ['pasty_mesa:8'],
},
};
next = applyAction(restored(next), 'p', second);
assert.equal(next.spice['pasty_mesa:8'], 2);
assert.equal(next.spice['red_chasm:7'], 5);
assert.equal(next.players[0].spice, g.players[0].spice + 2);
assert.equal(next.players[0].moved, 2);
conserved(next, g);
});
void test('interior passengers count as entering the territories the relocated stronghold points into', () => {
// User ruling 7 October 2026: a relocated HMS interior counts as entering the
// outside territories, so the Sandmaster skill collects there. Compare the
// identical relocation with and without the assigned skill.
const run = (skill: 'sandmaster' | 'smuggler') => {
let g = smugglerShipmentGame('ixians', false, skill, undefined, ['ix']);
g.players[0].forces = { [INSIDE]: 6 };
g.players[0].reserves = 14;
if (g.players[0].elites) g.players[0].elites.forces = { [INSIDE]: 3 };
g.mobileStronghold = { location: 'polar_sink:0' };
g.spice = {};
g.phase = 8;
g.active = null;
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
assert.equal(g.decision?.kind, 'mobileStronghold');
const route = mobileRoutes(g, 3).find((r) => mobileRouteDistance(r) === 3)!;
const end = route.at(-1)!;
g.spice[end] = 100;
const before = g.players[0].spice;
g = applyAction(g, 'p', { type: 'decision', route, collect: true });
while (g.response)
g = applyAction(
g,
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
{ type: 'passResponse' },
);
assert.equal(g.mobileStronghold!.location, end);
return { spice: g.players[0].spice - before, pile: g.spice[end] ?? 0 };
};
const without = run('smuggler');
const with_ = run('sandmaster');
assert.equal(with_.spice, without.spice + 1, 'Sandmaster collects one extra spice');
assert.equal(with_.pile, without.pile - 1);
});