Files
Dune/tests/mobile-stronghold.test.ts
T

599 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
battles,
createGame,
newPlayer,
viewGame,
type Game,
type Action,
} from '../game/engine';
import { baseDeck, spiceDeck } from '../game/cards';
import {
GRAPH,
TERRITORIES,
MOBILE_STRONGHOLD as HMS,
MOBILE_LOCATION as INSIDE,
gameTerritories,
gameDistance,
splitLocation,
mobileRoutes,
mobileRouteDistance,
} from '../game/board';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
function fixture() {
const g = createGame('HMSROOM2', newPlayer('i', 'Ixians', 'ixians'), false, [
'ix',
]);
g.players.push(
newPlayer('e', 'Emperor', 'emperor'),
newPlayer('a', 'Atreides', 'atreides'),
);
g.status = 'playing';
g.phase = 5;
g.active = 'i';
g.order = ['i', 'e', 'a'];
g.movementRemaining = [...g.order];
g.lastBattle = ['i', 'e'];
g.deck = baseDeck();
g.spiceDeck = spiceDeck();
g.mobileStronghold = { location: 'polar_sink:0' };
for (const p of g.players) p.spice = 20;
g.players[0].forces = { [INSIDE]: 6 };
g.players[0].reserves = 14;
g.players[0].elites = {
reserves: 4,
tanks: 0,
forces: { [INSIDE]: 3 },
revived: 0,
};
return g;
}
function ready(state: Game) {
let g = state;
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
return g;
}
function allow(state: Game) {
let g = state;
while (g.response)
g = applyAction(
g,
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
{ type: 'passResponse' },
);
return g;
}
function nextStorm(state = fixture()) {
state.phase = 8;
state.active = null;
return ready(ready(state)); // Mentat, then the Ix phase-opening window.
}
const ship = (amount = 1): Action => ({
type: 'ship',
territory: HMS,
sector: 0,
amount,
});
function conserve(g: Game) {
for (const p of g.players) {
assert.equal(
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
20,
);
if (p.elites)
assert.equal(
p.elites.reserves +
p.elites.tanks +
Object.values(p.elites.forces).reduce((a, b) => a + b, 0),
7,
);
}
}
void test('room-local stronghold topology never modifies the shared printed map', () => {
const g = fixture(),
other = fixture();
other.mobileStronghold = null;
assert.equal(gameTerritories(g).length, TERRITORIES.length + 1);
assert.equal(gameTerritories(other).length, TERRITORIES.length);
assert.equal(gameDistance(g, INSIDE, 'polar_sink:0'), 1);
assert.equal(gameDistance(g, INSIDE, INSIDE), 0);
assert.equal(gameDistance(other, INSIDE, INSIDE), Infinity);
assert.equal(
TERRITORIES.some((t) => t.id === HMS),
false,
);
other.mobileStronghold = { location: 'arsunt:11' };
assert.notEqual(
gameDistance(other, INSIDE, 'polar_sink:0'),
gameDistance(g, INSIDE, 'polar_sink:0'),
);
});
void test('first-storm placement happens after destruction and before the spice opening', () => {
let g = fixture();
g.phase = 0;
g.mobileStronghold!.location = null;
g.stormPending = 1;
g.storm = 17;
g.players[1].forces = { 'wind_pass_north:18': 2 };
g.players[1].reserves = 18;
g = ready(g);
assert.equal(g.storm, 18);
assert.equal(g.players[1].tanks, 2);
assert.equal(g.players[0].forces[INSIDE], 6);
assert.equal(g.phase, 0);
assert.equal(g.decision?.kind, 'mobileStronghold');
assert.throws(
() => applyAction(g, 'e', { type: 'decision', location: 'polar_sink:0' }),
/pending decision/,
);
for (const key of [
'carthag:11',
'polar_sink:18',
INSIDE,
'polar_sink:00',
'__proto__:0',
])
assert.throws(() =>
applyAction(g, 'i', { type: 'decision', location: key }),
);
g = applyAction(JSON.parse(JSON.stringify(g)), 'i', {
type: 'decision',
location: 'polar_sink:0',
});
assert.equal(g.phase, 1);
assert.ok(g.phaseOpening);
conserve(g);
});
void test('an undocked stronghold cannot receive shipments', () => {
const g = fixture();
g.mobileStronghold!.location = null;
assert.throws(() => applyAction(g, 'i', ship()), /not been placed/);
});
void test('Ixians ship directly at stronghold rates; other factions and allied Guild transport cannot', () => {
let g = fixture();
g = applyAction(g, 'i', { ...ship(2), elite: 1 });
assert.equal(g.players[0].spice, 18);
assert.equal(g.players[0].forces[INSIDE], 8);
conserve(g);
g.active = 'e';
assert.throws(() => applyAction(g, 'e', ship()), /Only Ixians/);
g.players[2].faction = 'guild';
g.players[1].ally = 'a';
g.players[2].ally = 'e';
g.players[1].forces = { 'polar_sink:0': 2 };
g.players[1].reserves = 18;
assert.throws(
() =>
applyAction(g, 'e', {
type: 'guildShip',
from: 'polar_sink:0',
territory: HMS,
sector: 0,
amount: 1,
}),
/Only Ixians/,
);
});
void test('Bene Gesserit can accompany an Ixian direct shipment inside', () => {
let g = fixture();
g.players[2].faction = 'beneGesserit';
g.advanced = true;
g = applyAction(g, 'i', ship());
assert.equal(g.decision?.kind, 'advisor');
g = applyAction(g, 'a', { type: 'decision', accept: true, accompany: true });
g = allow(g);
assert.equal(g.players[2].forces[INSIDE], 1);
conserve(g);
});
void test('the pointing sector is the only entrance and consumes a territory of movement', () => {
let g = fixture();
g.active = 'e';
g.players[1].forces = { 'polar_sink:0': 3 };
g.players[1].reserves = 17;
g = applyAction(g, 'e', {
type: 'move',
from: 'polar_sink:0',
territory: HMS,
sector: 0,
amount: 2,
});
assert.equal(g.players[1].forces[INSIDE], 2);
assert.equal(g.players[1].moved, 1);
conserve(g);
const neighbor = GRAPH['polar_sink:0'][0];
g = fixture();
g.active = 'e';
g.players[1].forces = { [neighbor]: 2 };
g.players[1].reserves = 18;
assert.throws(
() =>
applyAction(g, 'e', {
type: 'move',
from: neighbor,
territory: HMS,
sector: 0,
amount: 2,
}),
/more than 1/,
);
});
void test('storm blocks entrance, exit and battles through the stronghold’s pointing sector', () => {
const g = fixture();
const pointer = 'arsunt:11';
g.mobileStronghold!.location = pointer;
g.storm = 11;
assert.equal(
gameDistance(g, INSIDE, pointer, (key) => splitLocation(key).sector === 11),
Infinity,
);
assert.throws(
() =>
applyAction(g, 'i', {
type: 'move',
from: INSIDE,
territory: 'arsunt',
sector: 11,
amount: 2,
elite: 0,
}),
/storm/,
);
g.players[1].forces = { [INSIDE]: 2 };
g.players[1].reserves = 18;
assert.equal(battles(g).filter((b) => b.territory === HMS).length, 0);
g.storm = 12;
assert.equal(battles(g).filter((b) => b.territory === HMS).length, 1);
});
void test('inside forces are distinct from outside forces and enforce two-faction capacity', () => {
const g = fixture();
g.players[1].forces = { 'polar_sink:0': 2 };
g.players[1].reserves = 18;
assert.equal(battles(g).length, 0);
g.players[1].forces = { [INSIDE]: 2 };
g.players[2].forces = { 'polar_sink:0': 2 };
g.players[2].reserves = 18;
g.active = 'a';
assert.throws(
() =>
applyAction(g, 'a', {
type: 'move',
from: 'polar_sink:0',
territory: HMS,
sector: 0,
amount: 1,
}),
/three occupying factions/,
);
});
void test('a sole controller can use the mobile stronghold as the third winning stronghold', () => {
let g = fixture();
g.players[0].forces['arrakeen:10'] = 1;
g.players[0].forces['carthag:11'] = 1;
g.players[0].reserves -= 2;
g.phase = 7;
g = ready(ready(g));
assert.equal(g.status, 'finished');
assert.deepEqual(g.winner, ['i']);
conserve(g);
});
void test('relocation precedes later storm dials and canceled movement changes neither position nor spice', () => {
let g = nextStorm();
const route = mobileRoutes(g, 3).find((r) => mobileRouteDistance(r) === 3)!;
assert.equal(g.decision?.kind, 'mobileStronghold');
assert.equal(g.stormPending, null);
assert.throws(
() => applyAction(g, 'e', { type: 'stormDial', amount: 1 }),
/pending decision/,
);
g.spice[route.at(-1)!] = 15;
g.players[1].hand = [baseDeck().find((c) => c.effect === 'karama')!];
const before = structuredClone(g);
g = applyAction(g, 'i', { type: 'decision', route });
assert.equal(g.response?.kind, 'mobileStronghold');
assert.equal(g.mobileStronghold!.location, route[0]);
g = applyAction(JSON.parse(JSON.stringify(g)), 'e', {
type: 'card',
mode: 'cancel',
card: g.players[1].hand[0].id,
});
assert.equal(g.mobileStronghold!.location, before.mobileStronghold!.location);
assert.deepEqual(g.spice, before.spice);
assert.equal(g.players[0].spice, before.players[0].spice);
assert.equal(g.pendingMobileMove, null);
assert.equal(g.decision, null);
g = applyAction(g, 'e', { type: 'stormDial', amount: 1 });
assert.equal(g.stormDials.e, 1);
conserve(g);
});
void test('relocation collects only along the declared sectors and leaves all passengers inside', () => {
let g = nextStorm();
const route = mobileRoutes(g, 3).find((r) => mobileRouteDistance(r) === 3)!;
const end = route.at(-1)!;
g.spice[end] = 15;
const unvisited = Object.keys(GRAPH).find((key) => !route.includes(key))!;
g.spice[unvisited] = 9;
const units = structuredClone(g.players[0].forces);
g = allow(applyAction(g, 'i', { type: 'decision', route }));
assert.equal(g.mobileStronghold!.location, end);
assert.deepEqual(g.players[0].forces, units);
assert.equal(g.players[0].spice, 32);
assert.equal(g.spice[end], 3);
assert.equal(g.spice[unvisited], 9);
conserve(g);
});
void test('invalid, repeated, disconnected, overlong and stronghold-ending routes roll back', () => {
const g = nextStorm();
const before = structuredClone(g);
const long = mobileRoutes(g, 4).find((r) => mobileRouteDistance(r) === 4)!;
const routes: unknown[] = [
null,
{},
[1],
[],
['polar_sink:0'],
['__proto__', 'polar_sink:0'],
['polar_sink:0', 'carthag:11'],
['polar_sink:0', GRAPH['polar_sink:0'][0], 'polar_sink:0'],
long,
];
for (const route of routes) {
assert.throws(() => applyAction(g, 'i', { type: 'decision', route }));
assert.deepEqual(g, before);
}
});
void test('advanced storm card stays hidden until relocation is resolved', () => {
const initial = fixture();
initial.advanced = true;
initial.players[1].faction = 'fremen';
initial.stormCard = 4;
let g = nextStorm(initial);
assert.equal(g.stormPending, null);
assert.equal(g.stormCard, 4);
g = applyAction(g, 'i', { type: 'decision', decline: true });
assert.equal(g.stormPending, 4);
assert.equal(g.stormCard, null);
});
void test('a captured or empty stronghold cannot be relocated by another faction', () => {
const initial = fixture();
initial.players[0].forces = {};
initial.players[0].reserves = 20;
initial.players[0].elites!.forces = {};
initial.players[0].elites!.reserves = 7;
initial.players[1].forces = { [INSIDE]: 2 };
initial.players[1].reserves = 18;
const g = nextStorm(initial);
assert.equal(g.decision, null);
assert.deepEqual(g.stormDialers, ['i', 'e']);
conserve(g);
});
void test('special Karama relocates at most two territories without spending ordinary shipment or movement', () => {
let g = fixture();
g.advanced = true;
const card = baseDeck().find((c) => c.effect === 'karama')!;
g.players[0].hand = [card];
const route = mobileRoutes(g, 2).find((r) => mobileRouteDistance(r) === 2)!;
const long = mobileRoutes(g, 3).find((r) => mobileRouteDistance(r) === 3)!;
assert.throws(
() =>
applyAction(g, 'i', {
type: 'card',
mode: 'special',
card: card.id,
route: long,
}),
/at most 2/,
);
g = applyAction(g, 'i', {
type: 'card',
mode: 'special',
card: card.id,
route,
collect: false,
});
assert.equal(g.players[0].moved, 0);
assert.equal(g.players[0].shipped, false);
assert.equal(g.players[0].specialKaramaUsed, true);
assert.equal(g.mobileStronghold!.location, route.at(-1));
assert.equal(g.discard.at(-1)?.id, card.id);
conserve(g);
});
void test('special Karama retains a chosen partial stronghold collection', () => {
const g = fixture();
g.advanced = true;
const card = baseDeck().find((c) => c.effect === 'karama')!;
g.players[0].hand = [card];
const route = mobileRoutes(g, 2).find((r) => mobileRouteDistance(r) === 2)!;
const pile = route.at(-1)!;
g.spice[pile] = 5; // Controlled board-spice supplement for this special-use path.
const collections = route.map(location => ({ location, amount: location === pile ? 2 : 0 }));
const action = { type: 'card', mode: 'special', card: card.id, route, collect: true, collections };
const done = applyAction(g, 'i', action);
assert.equal(done.mobileStronghold!.location, pile);
assert.equal(done.spice[pile], 3);
assert.equal(done.players[0].spice, g.players[0].spice + 2);
assert.equal(done.players[0].specialKaramaUsed, true);
assert.equal(done.discard.at(-1)?.id, card.id);
conserve(done);
});
void test('all AI levels select legal placement and relocation using private projections', () => {
for (const difficulty of DIFFICULTIES) {
let g = nextStorm();
g.players[0].bot = difficulty;
const route = mobileRoutes(g, 3)[0];
g.spice[route.at(-1)!] = 6;
const choice = botActions(viewGame(g, 'i'))[0];
assert.ok(choice);
g = applyAction(g, 'i', choice);
assert.equal(g.response, null);
assert.equal(g.pendingMobileMove, null);
assert.equal('pendingMobileMove' in viewGame(g, 'e'), false);
g = fixture();
g.players[0].bot = difficulty;
g.mobileStronghold!.location = null;
g.decision = { kind: 'mobileStronghold', player: 'i', placement: true };
const placement = botActions(viewGame(g, 'i'))[0];
assert.ok(placement);
g = applyAction(g, 'i', placement);
assert.ok(g.mobileStronghold!.location);
}
});
void test('worm destruction targets the outside territory without entering the stronghold', () => {
let g = fixture();
const land = spiceDeck().find((c) => 'territory' in c)!;
assert.ok('territory' in land);
g.mobileStronghold!.location = `${land.territory}:${land.sector}`;
g.players[1].forces = { [g.mobileStronghold!.location]: 2 };
g.players[1].reserves = 18;
g.phase = 1;
g.turn = 2;
g.spiceDiscard = [[land], []];
g.spiceDeck = [
{ worm: true },
spiceDeck().find(
(c) => 'territory' in c && c.territory !== land.territory,
)!,
];
g = ready(g);
assert.equal(g.players[0].forces[INSIDE], 6);
assert.equal(g.players[1].tanks, 2);
conserve(g);
});
void test('occupation by another faction counts for its victory; contested or advisor-only presence does not', () => {
for (const mode of ['captured', 'contested', 'advisors'] as const) {
let g = fixture();
g.players[1].forces = { [INSIDE]: 2, 'arrakeen:10': 1, 'carthag:11': 1 };
g.players[1].reserves = 16;
if (mode !== 'contested') {
g.players[0].forces = {};
g.players[0].reserves = 20;
g.players[0].elites!.forces = {};
g.players[0].elites!.reserves = 7;
}
if (mode === 'advisors') {
g.advanced = true;
g.players[1].faction = 'beneGesserit';
g.players[1].advisors = { [HMS]: { lockedTurn: g.turn } };
// Enemy fighters keep the BG token from automatically flipping when alone.
g.players[0].forces = { [INSIDE]: 1 };
g.players[0].reserves = 19;
}
g.phase = 7;
g = ready(ready(g));
assert.deepEqual(g.winner, mode === 'captured' ? ['e'] : []);
conserve(g);
}
});
void test('all AI levels can use the special relocation and can propose normal movement out of the stronghold', () => {
for (const difficulty of DIFFICULTIES) {
let g = fixture();
g.advanced = true;
g.players[0].bot = difficulty;
g.players[0].hand = [baseDeck().find((c) => c.effect === 'karama')!];
const route = mobileRoutes(g, 2).find((r) => mobileRouteDistance(r) === 2)!;
g.spice[route.at(-1)!] = 12;
const choice = botActions(viewGame(g, 'i'))[0];
assert.equal(choice.mode, 'special');
g = applyAction(g, 'i', choice);
assert.equal(g.players[0].specialKaramaUsed, true);
g.players[0].shipped = true;
const candidate = botActions(viewGame(g, 'i')).find((a) => {
if (a.type !== 'move') return false;
try {
applyAction(g, 'i', a);
return true;
} catch {
return false;
}
});
assert.ok(candidate, `${difficulty} can leave the mobile stronghold`);
conserve(g);
}
});
void test('advanced advisor setup cannot use the not-yet-placed mobile stronghold', () => {
const g = fixture();
g.status = 'setup';
g.advanced = true;
g.players[2].faction = 'beneGesserit';
g.mobileStronghold!.location = null;
assert.throws(
() =>
applyAction(g, 'a', { type: 'advisorSetup', territory: HMS, sector: 0 }),
/not yet placed/,
);
assert.equal(g.players[2].reserves, 20);
});
void test('ordinary and special stronghold relocation reject storm departure, arrival and transit without spending resources', () => {
const base = nextStorm();
const route = mobileRoutes(base, 3).find(
(r) =>
r.length >= 3 &&
splitLocation(r.at(-1)!).sector > 0 &&
r
.slice(1, -1)
.some(
(key) =>
splitLocation(key).sector > 0 &&
splitLocation(key).sector !== splitLocation(r.at(-1)!).sector,
),
)!;
assert.ok(route);
const middle = route
.slice(1, -1)
.find(
(key) =>
splitLocation(key).sector > 0 &&
splitLocation(key).sector !== splitLocation(route.at(-1)!).sector,
)!;
for (const special of [false, true])
for (const mode of ['departure', 'arrival', 'transit'] as const) {
const g = structuredClone(base);
const chosen =
mode === 'departure' ? route.slice(route.indexOf(middle)) : route;
g.mobileStronghold!.location = chosen[0];
g.storm = splitLocation(
mode === 'arrival' ? chosen.at(-1)! : middle,
).sector;
if (special) {
g.phase = 5;
g.advanced = true;
g.active = 'i';
g.decision = null;
g.players[0].hand = [baseDeck().find((c) => c.effect === 'karama')!];
}
const before = structuredClone(g);
assert.throws(
() =>
applyAction(
g,
'i',
special
? {
type: 'card',
mode: 'special',
card: g.players[0].hand[0].id,
route: chosen,
}
: { type: 'decision', route: chosen },
),
/cannot move into, out of or through the storm/,
);
assert.deepEqual(g, before);
}
});
void test('AI route generation observes storm obstruction, including a storm-covered starting pointer', () => {
const g = fixture();
g.storm = 1;
const routes = mobileRoutes(g, 3);
assert.ok(routes.length);
assert.ok(
routes.every((route) =>
route.every((key) => splitLocation(key).sector !== 1),
),
);
g.mobileStronghold!.location = 'cielago_north:1';
assert.deepEqual(mobileRoutes(g, 3), []);
});