Files
Dune/tests/first-storm.test.ts
2026-09-08 07:37:52 +10:00

409 lines
13 KiB
TypeScript
Raw Permalink 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 { placeFixtureHand } from './fixture-hand';
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
joinGame,
newPlayer,
viewGame,
type Game,
} from '../game/engine';
import type { FactionId } from '../game/catalog';
import { baseDeck } from '../game/cards';
import {
PLAYER_CIRCLE_SECTORS,
STORM_START_SECTOR,
} from '../game/player-positions';
// Primary evidence: GF9 base rulebook pp4,6–7,22, and Nov2020 FAQ p1.
// https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf
// https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf
// The printed Meridian Storm Start is sector1 in this repository's board;
// bottom circle1 lies over Cielago South sector2, then circles proceed CCW.
const factions: FactionId[] = [
'atreides',
'harkonnen',
'emperor',
'guild',
'fremen',
'beneGesserit',
];
function lobby(count = 2, positions?: number[]) {
let g = createGame('STORM234', newPlayer('p0', 'Player 0', factions[0]));
for (let i = 0; i < count; i++) {
if (i) joinGame(g, newPlayer(`p${i}`, `Player ${i}`, factions[i]));
if (positions && g.playerPositions![`p${i}`] !== positions[i])
g = applyAction(g, `p${i}`, {
type: 'seatPosition',
position: positions[i],
});
}
return g;
}
function started(count = 2, positions?: number[]) {
let g = lobby(count, positions);
g.players.forEach((p) => (p.ready = true));
g = applyAction(g, 'p0', { type: 'start' });
const bg = g.players.find((p) => p.faction === 'beneGesserit');
if (bg)
g = applyAction(g, bg.id, {
type: 'predict',
faction: 'atreides',
turn: 10,
});
for (const p of g.players)
if (p.traitorChoices.length)
g = applyAction(g, p.id, {
type: 'traitor',
leader: p.traitorChoices[0],
});
const fremen = g.players.find((p) => p.faction === 'fremen');
if (fremen)
g = applyAction(g, fremen.id, {
type: 'fremenSetup',
placements: { sietch_tabr: 4, false_wall_south: 3, false_wall_west: 3 },
});
assert.equal(g.status, 'playing');
return g;
}
function confirmStorm(g: Game) {
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
return g;
}
function reveal(g: Game, a: number, b: number) {
g = applyAction(g, g.stormDialers[0], { type: 'stormDial', amount: a });
return applyAction(g, g.stormDialers[1], { type: 'stormDial', amount: b });
}
function forceTotal(g: Game) {
return g.players.map(
(p) =>
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
);
}
void test('new tables use the six fixed printed circles and the Meridian Storm Start', () => {
assert.equal(STORM_START_SECTOR, 1);
assert.deepEqual(PLAYER_CIRCLE_SECTORS, [2, 5, 8, 11, 14, 17]);
for (let count = 2; count <= 6; count++) {
const g = lobby(count),
before = JSON.stringify(g);
assert.equal(g.storm, 1);
assert.deepEqual(
viewGame(g, 'p0').playerPositions,
Object.fromEntries(g.players.map((p, i) => [p.id, i + 1])),
);
assert.equal(JSON.stringify(g), before, 'read projection is pure');
}
});
void test('only the owner can choose an unoccupied circle and readiness resets', () => {
let g = lobby(3);
g.players[2].bot = 'Easy';
g.players.forEach((p) => (p.ready = true));
for (const position of [0, 7, -1, 1.5, '4', NaN, Infinity]) {
const before = JSON.stringify(g);
assert.throws(
() => applyAction(g, 'p0', { type: 'seatPosition', position }),
/Player circle/,
);
assert.equal(JSON.stringify(g), before);
}
assert.throws(
() =>
applyAction(g, 'p0', { type: 'seatPosition', position: 4, target: 'p1' }),
/own/,
);
assert.throws(
() => applyAction(g, 'p0', { type: 'seatPosition', position: 2 }),
/occupied/,
);
assert.throws(
() => applyAction(g, 'p0', { type: 'seatPosition', position: 1 }),
/already/,
);
g = applyAction(g, 'p0', { type: 'seatPosition', position: 4 });
assert.deepEqual(g.playerPositions, { p0: 4, p1: 2, p2: 3 });
assert.deepEqual(
g.players.map((p) => p.ready),
[false, false, true],
);
assert.deepEqual(viewGame(g, 'p1').playerPositions, g.playerPositions);
});
void test('join and bot replacement reuse vacancies without moving existing circles', () => {
let g = lobby(2, [6, 4]);
g = applyAction(g, 'p0', {
type: 'addBot',
faction: 'emperor',
difficulty: 'Medium',
});
const bot = g.players.find((p) => p.bot)!;
assert.deepEqual(g.playerPositions, { p0: 6, p1: 4, [bot.id]: 1 });
g = applyAction(g, 'p0', { type: 'removeBot', target: bot.id });
assert.deepEqual(g.playerPositions, { p0: 6, p1: 4 });
joinGame(g, newPlayer('replacement', 'Replacement', 'emperor'));
assert.deepEqual(g.playerPositions, { p0: 6, p1: 4, replacement: 1 });
g = applyAction(g, 'p1', { type: 'faction', faction: 'guild' });
assert.deepEqual(g.playerPositions, { p0: 6, p1: 4, replacement: 1 });
assert.throws(
() => joinGame(g, newPlayer('replacement', 'Retry', 'fremen')),
/Already seated/,
);
});
void test('start chooses both dialers from occupied circles beside Storm Start, not player-array endpoints', () => {
for (const count of [2, 3, 4, 5, 6]) {
const assigned = [3, 6, 1, 5, 2, 4].slice(0, count);
const g = started(count, assigned);
const expected = g.players
.map((p, i) => ({ id: p.id, circle: assigned[i] }))
.sort((a, b) => a.circle - b.circle)
.map((p) => p.id);
assert.deepEqual(g.order, expected);
assert.deepEqual(g.stormDialers, [expected[0], expected.at(-1)]);
assert.deepEqual(g.lastBattle, g.stormDialers);
assert.equal(g.storm, 1);
assert.throws(
() => applyAction(g, 'p0', { type: 'seatPosition', position: 4 }),
/not available|Unknown action/,
);
}
});
for (const [a, b, final] of [
[0, 0, 1],
[0, 1, 2],
[17, 0, 18],
[18, 0, 1],
[19, 20, 4],
[20, 20, 5],
]) {
void test(`first storm ${a}+${b} moves from sector1 to sector${final}, including zero and complete circuits`, () => {
let g = started(3, [3, 6, 1]);
const [first, second] = g.stormDialers;
assert.throws(
() => applyAction(g, first, { type: 'stormDial', amount: 21 }),
/Storm dial/,
);
assert.throws(
() => applyAction(g, first, { type: 'stormDial', amount: -1 }),
/Storm dial/,
);
assert.throws(
() => applyAction(g, 'p0', { type: 'stormDial', amount: 1 }),
/not dialing/,
);
g = applyAction(g, first, { type: 'stormDial', amount: a });
assert.equal(viewGame(g, second).stormRevealed, null);
assert.equal('stormDials' in viewGame(g, second), false);
assert.equal(g.stormPending, null);
assert.throws(
() => applyAction(g, first, { type: 'stormDial', amount: a }),
/locked/,
);
g = JSON.parse(JSON.stringify(g)) as Game;
g = applyAction(g, second, { type: 'stormDial', amount: b });
assert.deepEqual(viewGame(g, 'p0').stormRevealed, {
[first]: a,
[second]: b,
});
assert.equal(g.stormPending, a + b);
assert.equal(g.storm, 1, 'movement waits for confirmations');
g = confirmStorm(g);
assert.equal(g.storm, final);
assert.equal(g.phase, 1);
assert.deepEqual(g.stormDials, {});
assert.deepEqual(forceTotal(g), [20, 20, 20]);
});
}
void test('every storm sector chooses the next occupied printed circle counterclockwise, skipping its current circle', () => {
const firstBySector = [0, 1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4, 5, 5, 5, 0, 0];
const initial = started(6);
for (let sector = 1; sector <= 18; sector++) {
let g = structuredClone(initial);
g.storm = sector;
g.stormPending = 0;
g = confirmStorm(g);
const first = firstBySector[sector - 1];
assert.deepEqual(
g.order,
Array.from({ length: 6 }, (_, i) => `p${(first + i) % 6}`),
`sector ${sector}`,
);
}
});
void test('sparse tables skip empty circles without redistributing four or five players around the map', () => {
for (const positions of [
[1, 3, 5, 6],
[1, 2, 3, 5, 6],
]) {
let g = started(positions.length, positions);
g.storm = 8;
g.stormPending = 0;
g = confirmStorm(g);
const circles = g.order.map((id) => g.playerPositions![id]);
assert.deepEqual(
circles,
positions.length === 4 ? [5, 6, 1, 3] : [5, 6, 1, 2, 3],
);
}
});
void test('first-storm damage follows the traversed sectors; normal Basic starting forces remain sheltered', () => {
let g = started(6);
const startingForces = g.players.map((p) => structuredClone(p.forces));
g.spice = { 'meridian:1': 4, 'cielago_south:2': 7 };
g = confirmStorm(reveal(g, 20, 20));
assert.deepEqual(
g.players.map((p) => p.forces),
startingForces,
);
assert.deepEqual(g.spice, {});
assert.deepEqual(forceTotal(g), [20, 20, 20, 20, 20, 20]);
// Exercise the published damage rule if a force is present in exposed sand.
g = started();
g.players[0].forces['arrakeen:10']--;
g.players[0].forces['cielago_south:2'] = 1;
g.spice = { 'meridian:1': 4, 'cielago_south:2': 7 };
g = confirmStorm(reveal(g, 0, 1));
assert.equal(g.players[0].tanks, 1);
assert.equal(g.players[0].forces['cielago_south:2'] ?? 0, 0);
assert.deepEqual(g.spice, { 'meridian:1': 4 });
});
void test('Weather Control may replace revealed first dials, including zero, without changing the Storm Start', () => {
for (const distance of [0, 2]) {
let g = started();
const weather = baseDeck().find((c) => c.effect === 'weather')!;
placeFixtureHand(g, 0, [weather]);
g = reveal(g, 20, 20);
g = applyAction(g, 'p1', { type: 'ready' });
g = applyAction(g, 'p0', {
type: 'card',
card: weather.id,
amount: distance,
});
assert.deepEqual(g.ready, []);
assert.equal(g.storm, 1);
g = confirmStorm(g);
assert.equal(g.storm, 1 + distance);
assert.ok(g.discard.some((c) => c.id === weather.id));
}
});
void test('later Basic storms use the last battle-wheel users and the 1–3 dial limits', () => {
let g = confirmStorm(reveal(started(4), 0, 0));
g.phase = 8;
g.lastBattle = ['p2', 'p1'];
g.ready = [];
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
assert.equal(g.turn, 2);
assert.equal(g.phase, 0);
assert.deepEqual(g.stormDialers, ['p2', 'p1']);
for (const amount of [0, 4, 20])
assert.throws(
() => applyAction(g, 'p2', { type: 'stormDial', amount }),
/Storm dial/,
);
g = confirmStorm(reveal(g, 1, 3));
assert.equal(g.storm, 5);
assert.deepEqual(g.order, ['p2', 'p3', 'p0', 'p1']);
});
void test('with no battles, the same last wheel users dial the next Basic storm', () => {
let g = confirmStorm(reveal(started(4, [3, 6, 1, 5]), 0, 0));
const dialers = [...g.stormDialers];
g.phase = 8;
g.ready = [];
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
assert.equal(g.turn, 2);
assert.deepEqual(g.stormDialers, dialers);
assert.deepEqual(g.stormDialers, ['p2', 'p1']);
});
void test('legacy reads derive circles without mutation and the first authoritative action preserves pending state', () => {
let g = started(4);
delete g.playerPositions;
g.turn = 3;
g.phase = 1;
g.storm = 7;
g.order = ['p3', 'p1', 'p0', 'p2'];
g.stormDialers = ['p1', 'p3'];
g.stormDials = { p1: 2 };
g.decision = {
kind: 'wormProtection',
player: 'p0',
territory: 'cielago_south',
ally: 'p1',
};
const before = structuredClone(g);
assert.deepEqual(viewGame(g, 'p1').playerPositions, {
p0: 1,
p1: 2,
p2: 3,
p3: 4,
});
assert.deepEqual(g, before);
g = applyAction(g, 'p0', { type: 'advanceBots' });
assert.equal(g.storm, before.storm);
assert.equal(g.phase, before.phase);
assert.deepEqual(g.order, before.order);
assert.deepEqual(g.stormDialers, before.stormDialers);
assert.deepEqual(g.stormDials, before.stormDials);
assert.deepEqual(g.decision, before.decision);
assert.deepEqual(
g.players.map((p) => p.hand),
before.players.map((p) => p.hand),
);
assert.equal(
g.log.filter((entry) =>
entry.text.startsWith('Assigned printed player circles'),
).length,
1,
);
g = applyAction(JSON.parse(JSON.stringify(g)) as Game, 'p0', {
type: 'advanceBots',
});
assert.equal(
g.log.filter((entry) =>
entry.text.startsWith('Assigned printed player circles'),
).length,
1,
);
g.phase = 0;
g.decision = null;
g.stormPending = 1;
g.ready = [];
g = confirmStorm(g);
assert.equal(g.storm, 8);
assert.deepEqual(
g.order,
['p3', 'p0', 'p1', 'p2'],
'next completed storm uses corrected circles',
);
});
void test('an old lobby normalizes its positions and begins at the corrected Storm Start', () => {
let g = lobby(3);
delete g.playerPositions;
g.storm = 18;
assert.equal(viewGame(g, 'p0').storm, 1);
assert.equal(g.storm, 18, 'lobby projection does not write saved state');
g = applyAction(g, 'p0', { type: 'seatPosition', position: 6 });
assert.equal(g.storm, 1);
assert.deepEqual(g.playerPositions, { p0: 6, p1: 2, p2: 3 });
assert.equal(
g.log.filter((entry) =>
entry.text.startsWith('Assigned printed player circles'),
).length,
0,
);
g.players.forEach((p) => (p.ready = true));
g = applyAction(g, 'p0', { type: 'start' });
assert.equal(g.storm, 1);
assert.deepEqual(g.stormDialers, ['p1', 'p0']);
});