409 lines
13 KiB
TypeScript
409 lines
13 KiB
TypeScript
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']);
|
||
});
|