v0.4.0 — multiplayer Phases 0 and 1: seat and player split apart, turn state per player, the page behind a Session, and eight seat/player mix-ups fixed with tests that fail without them

This commit is contained in:
Jesse
2026-08-13 14:06:02 -04:00
parent 216006b091
commit 49f8504b05
34 changed files with 1743 additions and 526 deletions
+26 -4
View File
@@ -7,6 +7,7 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { turnOf } from '../src/engine/state.ts';
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
@@ -1520,6 +1521,27 @@ describe('the static build', () => {
}
});
it('renders from the Frame and the Menu, never from GameState', () => {
/**
* THE PROPERTY THAT MAKES A REMOTE CLIENT POSSIBLE.
*
* `main.ts` used to reach into `game.state` in eleven places — the phase, the outcome, another
* player's name, the hand count. That is free with the engine in the same process and impossible
* with a server, where the client holds no state at all: it has neither the deck order nor
* anyone else's hand, and could not be given them without handing over the game.
*
* Everything it needs now lives on `Frame` and `Menu`. This is a cheap guard on a property that
* is very easy to lose — one `game.state.clock.day` would compile, run, and quietly make the
* page unable to run against a server. See `docs/architecture/multiplayer.md` §5.
*/
const src = readFileSync(join(root, 'src/web/main.ts'), 'utf8');
const reads = [...src.matchAll(/game\.state[.[]/g)];
assert.equal(
reads.length, 0,
`main.ts reaches into game.state ${reads.length} time(s); it must render from Frame + Menu`,
);
});
it('asks only for elements the page actually has', () => {
// Cheap and total: compare every $('id') in the source against the ids in the served HTML.
// Getting this wrong does not degrade the page, it stops the game starting at all.
@@ -1826,7 +1848,7 @@ describe('the static build', () => {
{ type: 'caboose', loaded: false },
],
direction: 'east', facing: 'e',
position: { at: 'grid', owner: 0, coord: { row: area.runningRow, col: 0 } }, movesUsed: 0,
position: { at: 'grid', seat: 0, coord: { row: area.runningRow, col: 0 } }, movesUsed: 0,
} as never);
const cell = view(game).cells.find((c) => c.row === area.runningRow && c.col === 0)!;
@@ -1862,7 +1884,7 @@ describe('the static build', () => {
{ type: 'caboose', loaded: false },
],
direction: facing === 'e' ? 'east' : 'west', facing,
position: { at: 'grid', owner: 0, coord: { row: area.runningRow, col: 0 } }, movesUsed: 0,
position: { at: 'grid', seat: 0, coord: { row: area.runningRow, col: 0 } }, movesUsed: 0,
} as never);
const cell = view(game).cells.find((c) => c.row === area.runningRow && c.col === 0)!;
const svg = officeSvg([cell], area.runningRow);
@@ -1899,7 +1921,7 @@ describe('the static build', () => {
id, trainNumber: null, trainIsExtra: false, engineAt: 1,
consist: [{ type: 'boxcar', loaded: true }, { type: 'caboose', loaded: false }],
direction: 'east', facing: 'e',
position: { at: 'grid', owner: 0, coord: { row: area.runningRow, col: 1 } }, movesUsed: 0,
position: { at: 'grid', seat: 0, coord: { row: area.runningRow, col: 1 } }, movesUsed: 0,
} as never);
const front = describeIntent(game.state, { type: 'switch.dropCars', trayId: id, count: 1, fromNose: true });
const back = describeIntent(game.state, { type: 'switch.dropCars', trayId: id, count: 1 });
@@ -2276,7 +2298,7 @@ describe('the hand limit is a limit, not a toll on drawing (regression)', () =>
if (currentActor(game) === null) break;
const { options, groups } = actionGroups(game);
if (groups.length === 0 || options.length === 0) break;
if (game.state.clock.phase === 'localOps' && game.state.turn.option === 'draw') {
if (game.state.clock.phase === 'localOps' && turnOf(game.state, 0).option === 'draw') {
const engineAllows = options.some((o) => o.type === 'draw.end');
assert.equal(
engineAllows,