408 lines
16 KiB
TypeScript
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);
|
|
});
|