Initial commit

This commit is contained in:
Quintine committed 2026-09-08 07:37:52 +10:00
commit 8c709fb52f
771 files changed
+222983

No files matched your search

+408
View File
@@ -0,0 +1,408 @@
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']);
});