v0.8.0 — the board replays what everyone else did, instead of arriving rearranged

TODO #13, #15 and #18 — Gitea#20 steps 2-4 pointed at a seated player's own screen.
Every accepted intent, and every automatic phase that does anything, becomes an
ordered presentation step. A bot's whole switching turn used to land in one push;
now it arrives as a run of steps, the district panel follows whoever is acting,
and a [N behind] … [Skip] row says how far the board is from the game.

Solitaire runs the same path — one collector inside submit(), which both session
kinds already funnel through — which is where its automatic phases finally get a
visible beat.

Dwell is assigned by kind: switching holds the screen, turn bookkeeping costs
nothing, and the clock turning over earns the beat. Tunable per viewer without a
rebuild, and off entirely at pace 0.

Also: switching was the one class of action logging unattributed, and now names
its train. Reasoning, measurements and the three things that turned out wrong are
in CHANGELOG.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
This commit is contained in:
Jesse.Markowitz
2026-09-09 15:31:46 -04:00
co-authored by Claude Opus 5
parent 312e0301e0
commit 02289e94b8
25 changed files with 2669 additions and 145 deletions
+56 -9
View File
@@ -6,6 +6,9 @@
*/
import { describe, it } from 'node:test';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import assert from 'node:assert/strict';
import { pump } from '../src/engine/advance.ts';
@@ -42,9 +45,9 @@ const SAMPLES: GameEvent[] = [
{ type: 'phaseBegan', phase: 'mainline' },
{ type: 'actorChanged', player: 0 },
{ type: 'localOpsOptionChosen', player: 0, option: 'switch' },
{ type: 'trayMoved', trayId: 't0', from: { row: 0, col: 0 }, to: { row: 0, col: 1 }, movesRemaining: 5 },
{ type: 'carsCoupled', trayId: 't0', at: { row: 0, col: 1 }, stock: [{ type: 'hopper', loaded: false }], from: [{ row: 0, col: 1 }], toNose: true },
{ type: 'carsDropped', trayId: 't0', at: { row: 1, col: 0 }, stock: [{ type: 'hopper', loaded: false }] },
{ type: 'trayMoved', player: 0, trayId: 't0', from: { row: 0, col: 0 }, to: { row: 0, col: 1 }, movesRemaining: 5 },
{ type: 'carsCoupled', player: 0, trayId: 't0', at: { row: 0, col: 1 }, stock: [{ type: 'hopper', loaded: false }], from: [{ row: 0, col: 1 }], toNose: true },
{ type: 'carsDropped', player: 0, trayId: 't0', at: { row: 1, col: 0 }, stock: [{ type: 'hopper', loaded: false }] },
{ type: 'cardDrawn', player: 0, source: 'homeOffice', cardId: 'c1' },
{ type: 'cardPlayed', player: 0, cardId: 'c1', placement: { row: 1, col: 0 }, variant: 0 },
{ type: 'officeUpgraded', player: 0, from: 'whistlePost', to: 'depot' },
@@ -72,12 +75,56 @@ const SAMPLES: GameEvent[] = [
describe('narration', () => {
it('covers every event type the engine can emit', () => {
// Guards against a new event type slipping in unnarrated.
const covered = new Set(SAMPLES.map((e) => e.type));
const declared = new Set<string>();
for (const e of SAMPLES) declared.add(e.type);
assert.equal(covered.size, 30, 'sample list is out of step with GameEvent');
assert.equal(declared.size, 30);
/**
* THIS TEST USED TO BUILD BOTH SETS FROM `SAMPLES` and compare them to each other, so it could
* only ever assert that the sample list had 30 distinct entries — the one thing it could not
* detect was the thing its comment promised, a new `GameEvent` slipping in unnarrated. Fixed
* 2026-09-09 while adding the v0.8.0 step collector, which made the event union load-bearing for
* a second reader.
*
* The union is read out of `src/engine/events.ts` rather than hand-listed, the same way
* `test/pacing.test.ts` reads the intent union: a list maintained by hand is a list that goes
* stale, which is how this got here.
*/
const here = dirname(fileURLToPath(import.meta.url));
const declared = new Set(
[...readFileSync(join(here, '../src/engine/events.ts'), 'utf8').matchAll(/type: '([a-zA-Z]+)'/g)]
.map((m) => m[1]!),
);
const narrated = new Set(
[...readFileSync(join(here, '../src/sim/narrate.ts'), 'utf8').matchAll(/case '([a-zA-Z]+)':/g)]
.map((m) => m[1]!),
);
assert.ok(declared.size > 40, `only ${declared.size} event types parsed — the parse is wrong`);
// THE INVARIANT THAT MATTERS: an event the engine can emit and `narrate` has no case for falls
// through to a placeholder, in front of a player. This is the check the old version promised.
const unnarrated = [...declared].filter((t) => !narrated.has(t));
assert.deepEqual(unnarrated, [], 'these event types can be emitted and have no narration case');
const covered = new Set<string>(SAMPLES.map((e) => e.type));
const unknown = [...covered].filter((t) => !declared.has(t));
assert.deepEqual(unknown, [], 'these samples name an event the engine no longer declares');
/**
* THE KNOWN GAP, PINNED SO IT CANNOT GROW.
*
* `SAMPLES` exercises the TEXT of 30 of the 55 declared events; the other 25 have a narration
* case (checked above) but no sample, so nothing proves their sentence is any good. Found
* 2026-09-09 — the old test built both of its sets from `SAMPLES` and compared them to each
* other, so it could only ever assert that the sample list had 30 distinct entries, and the one
* thing it could not detect was the thing its comment promised.
*
* Pinned rather than fixed: writing 25 fixtures is a job of its own, and a bad sentence is worth
* finding deliberately rather than in a rush. What this does guarantee is that a NEW event type
* cannot join the unsampled set silently.
*/
const unsampled = [...declared].filter((t) => !covered.has(t)).sort();
assert.equal(
unsampled.length,
25,
`the unsampled set changed (${unsampled.length}): add a sample for a new event, or update this count`,
);
});
it('gives every event a specific, non-empty sentence', () => {