v0.8.0.13 — the board on screen is the board you can act on

Nine reports from the Day 1-2 playtest of v0.8.0.12. One moved a car, one was a
rule working correctly with nothing on screen to say so, and the rest are things
the table could not see.

The real bug: the Division Yard chips stayed lit and clickable while the board
was catching up. `renderActions` puts the action list away while the queue is
behind — a move offered against a position that has already moved on is a move
made blind — but the make-up wiring sat outside that guard. A chip was clicked
during a bot's make-up, a coach left the yard, and the train ended up with three
cars: a real intent submitted against a board several moves stale. The chips now
follow the queue like every other control, and the yard COUNTS are drawn from the
shown board rather than the live game — they were the one panel still reporting a
future the player had not been shown.

The Office Area picker had a button per opponent and none for yourself, so the
one player who could not reach their own district was the player waiting on
everybody else. Your own seat is in the row now, and the row is ordered by SEAT,
west to east as the Division map draws it, rather than by join order — sorted
from the Frame's own `seat` on every render, so it rotates with Employee Rotation
instead of having to be told.

§5's handover of the Fedora rode on `actorChanged`, which is turn bookkeeping and
which `record()` drops as noise, so the one moment it carried that a player needed
went past in silence. It is its own event now, narrated and announced. The phase
keeps its name: the Supervisor Shift refreshes every Laborer and Porter EVERY
Stage and the Fedora moves only every third.

A collision now names whose Office it was and who paid the 5 Revenue, which rode
in a separate `revenueChanged`; a Mainline collision is phrased differently
because §10 makes it the Superintendent's.

Passengers, reported as a bug and ruled not one after replaying the save: the
Depot's capacity and modifiers were fine, and §6.3 stocking wants a LOADED coach
out of the Division Yard, which held none while six sat in Classification. The
shortage stays — running out is part of the game, the same ruling Gitea#2 got —
but the blocked panel says so now instead of the action being silently absent.

Smaller: "working left" is "working eastward" in the make-up panel and the New
Train tip, because the map runs west to east and the table does not; the history
panel keeps 90 lines instead of 60 in the same 230px box.

`git diff v0.8.0.12..v0.8.0.13 -- src/engine/` is NOT empty this time:
`events.ts` declares `superintendentChanged` and `advance.ts` emits it. Both are
additive — `check()`, `legal.ts` and every predicate are untouched, and events are
derived by replaying a save rather than stored — so no once-legal move became
illegal and games in progress resume.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DmdqqCNoiqE7GBo6wthBnR
This commit is contained in:
Jesse.Markowitz
2026-09-17 04:43:59 -04:00
co-authored by Claude Opus 5
parent 7c9ef8797d
commit ad277fb994
12 changed files with 340 additions and 29 deletions
+6
View File
@@ -61,6 +61,12 @@ const KNOWN_UNREDUCED = [
// the pattern every entry on this list follows.
'seatsRotated',
'stageBegan',
/**
* §5's Fedora handover, emitted by `shiftChange` on the same mutate-then-describe path as its
* neighbours here: the clock moves the Superintendent and then says so. Added 2026-09-16 because
* riding on `actorChanged` meant the log dropped it as turn bookkeeping.
*/
'superintendentChanged',
'trainArrived',
'trainCompleted',
'trainDiverted',
+3
View File
@@ -44,6 +44,9 @@ const SAMPLES: GameEvent[] = [
{ type: 'stageBegan', day: 1, stage: 7 },
{ type: 'phaseBegan', phase: 'mainline' },
{ type: 'actorChanged', player: 0 },
// Sampled rather than left to swell the unsampled count: this sentence is one a player reads at
// the table every third Stage, so its text is worth exercising.
{ type: 'superintendentChanged', player: 1, stage: 6 },
{ type: 'localOpsOptionChosen', player: 0, option: 'switch' },
{ 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 },
+27
View File
@@ -310,6 +310,33 @@ describe('steps reach a seated player — TODO #13', () => {
});
});
describe('the Fedora passing is visible (playtest 2026-09-16)', () => {
it('names the new Superintendent in the history at the Stage it happens', async () => {
const { newMultiplayerGame, currentActor, submit } = await import('../src/web/game.ts');
const { legalActions } = await import('../src/engine/legal.ts');
/**
* It used to ride on `actorChanged`, which `record()` drops as turn bookkeeping — so the one
* moment that event meant something never reached a player. Driven far enough to cross a shift
* boundary (Stages 3, 6, 9, 12) rather than asserted on a hand-built event, because the point is
* that a real game produces the line.
*/
const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']);
for (let i = 0; i < 900; i++) {
const actor = currentActor(game);
if (actor === null) break;
const options = legalActions(game.state, actor);
if (options.length === 0) break;
if (!submit(game, options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
if (game.state.clock.stage > 3 || game.state.clock.day > 1) break;
}
const handover = game.log.filter((l) => /SUPERINTENDENT — the Fedora passes to/.test(l.text));
assert.ok(handover.length > 0, 'the game crossed a shift change and the log never said so');
assert.match(handover[0]!.text, /Alice|Bob|Carol/, 'the handover did not name the new Superintendent');
});
});
describe('the log says who acted, once, and in what capacity (Gitea#30, #31)', () => {
it('never names a player twice — no "Player Jesse player 0 finished …"', async () => {
const { newMultiplayerGame, currentActor, submit } = await import('../src/web/game.ts');
+48 -3
View File
@@ -1522,6 +1522,15 @@ describe('the static build', () => {
attrs,
setAttribute: (k: string, v: string) => void (attrs[k] = v),
getAttribute: (k: string) => attrs[k] ?? null,
/**
* A STUB THAT CANNOT MODEL A METHOD SHIPS THE CONTROL GREEN AND UNEXERCISED — the lesson
* `setAttribute` taught this factory above, learned again on 2026-09-16.
*
* The Office Area's per-seat buttons are built with `createElement` and appended, because a
* display name is another player's text and must never be interpolated into markup. Without
* this the district panel throws on every render.
*/
appendChild: () => {},
textContent: '', style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0,
title: '', returnValue: '', open: false,
addEventListener: (type: string, fn: (e?: unknown) => void) =>
@@ -1779,6 +1788,15 @@ describe('the static build', () => {
getAttribute: (k: string) => attrs[k] ?? null,
textContent: '', style: {}, dataset: {}, onclick: null, disabled: false,
title: '', returnValue: '', open: false,
/**
* A STUB THAT CANNOT MODEL A METHOD SHIPS THE CONTROL GREEN AND UNEXERCISED — the lesson
* `setAttribute` taught these factories in 2026-08-30, learned again on 2026-09-16.
*
* The Office Area's per-seat buttons are built with `createElement` and appended, because a
* display name is another player's text and must never be interpolated into markup. Without
* this the district panel throws on every render.
*/
appendChild: () => {},
addEventListener: (type: string, fn: (e?: unknown) => void) =>
void listeners.set(type, [...(listeners.get(type) ?? []), fn]),
showModal() {
@@ -1894,6 +1912,15 @@ describe('the static build', () => {
getAttribute: (k: string) => attrs[k] ?? null,
textContent: '', style: {}, dataset: {}, onclick: null, disabled: false,
title: '', returnValue: '', open: false,
/**
* A STUB THAT CANNOT MODEL A METHOD SHIPS THE CONTROL GREEN AND UNEXERCISED — the lesson
* `setAttribute` taught these factories in 2026-08-30, learned again on 2026-09-16.
*
* The Office Area's per-seat buttons are built with `createElement` and appended, because a
* display name is another player's text and must never be interpolated into markup. Without
* this the district panel throws on every render.
*/
appendChild: () => {},
addEventListener: () => {},
showModal() {},
close() {},
@@ -2680,10 +2707,15 @@ describe('the static build', () => {
it('names the Superintendent at a table, and stays quiet about it in solitaire', () => {
/**
* REPORTED BY JESSE 2026-08-23, playing two-player on StartOS: seat 1 played a train card and
* seat 2 was asked to build the train. The engine was right — §7 makes a consist up "starting
* with the Superintendent and working left" — but nothing on the board said who the
* seat 2 was asked to build the train. The engine was right — §7 makes a consist up starting
* with the Superintendent and working EASTWARD — but nothing on the board said who the
* Superintendent WAS, so the question could not be answered from the screen. The Frame has
* carried `superintendent` since v0.4.0 and only the standalone replay ever drew it.
*
* The rule text said "working left" until 2026-09-16. It is the same rule — `playerLeftOf` is
* increasing seat index — but "left" describes a table nobody is looking at, while the map on
* screen runs west to east, so at a real three-player game it read as plainly wrong: the second
* car went to the player sitting to the EAST. The word changed; the order did not.
*/
const frame = { day: 1, stage: 4, clock: '2:00', phase: 'New Train', phaseKey: 'newTrain', actor: 1 };
const table = turnChartHtml(frame, 'Bob', 'Bob');
@@ -2697,7 +2729,7 @@ describe('the static build', () => {
const src = readFileSync(join(root, 'src/sim/turnchart.ts'), 'utf8');
assert.match(
src,
/starting with the Superintendent and working left/,
/starting with the Superintendent and working eastward/,
'the New Train pill does not say whose turn the make-up round starts on',
);
});
@@ -4170,6 +4202,16 @@ describe('the lobby screen', () => {
addEventListener: () => {}, showModal: () => {}, close: () => {}, focus: () => {},
querySelectorAll: (sel: string) => matching(sel),
querySelector: (sel: string) => matching(sel)[0] ?? null,
/**
* A STUB THAT CANNOT MODEL A METHOD SHIPS THE CONTROL GREEN AND UNEXERCISED — the same
* lesson `setAttribute` taught this factory in 2026-08-30, learned again on 2026-09-16.
*
* The Office Area's per-seat buttons are built with `createElement` and appended, because a
* display name is another player's text and must never be interpolated into markup. Without
* this the district panel threw on every render, which took out 21 tests across three suites
* — and the thing it was hiding was a control nothing had ever exercised.
*/
appendChild: () => {},
};
Object.defineProperty(node, 'innerHTML', { get: () => html, set: (v: string) => void (html = v) });
return node;
@@ -4567,6 +4609,9 @@ describe('the solitaire setup screen', () => {
style: {}, dataset: {}, onclick: null, oninput: null, onchange: null, scrollTop: 0, scrollHeight: 0,
checked: false, disabled: false, hidden: false, className: '',
classList: { add: () => {}, remove: () => {}, contains: () => false, toggle: () => {} },
// The Office Area's per-seat buttons are created and appended rather than interpolated, so a
// node that cannot be appended to throws on every render — see the note in the other factory.
appendChild: () => {},
addEventListener: (type: string, fn: () => void) =>
void listeners.set(type, [...(listeners.get(type) ?? []), fn]),
showModal: () => void ((node as { open: boolean }).open = true),