Initial commit
This commit is contained in:
commit
8c709fb52f
771 files changed
+222983
No files matched your search
@@ -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']);
|
||||
});
|
||||
Reference in new issue
Block a user