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
+76 -21
View File
@@ -337,9 +337,17 @@ function watchedDistrict(f: Frame): PublicDistrict | null {
* A PHASE STEP NAMES NOBODY — the Mainline advances itself — so it falls through to the actor,
* which keeps the board where it was instead of snapping home mid-sequence.
*/
// A deliberate look wins over whoever happens to be acting, for this one render (see `peekPlayer`).
if (peekPlayer !== null && peekPlayer !== f.viewer) {
return pub.districts.find((d) => d.player === peekPlayer) ?? null;
/**
* A deliberate look wins over whoever happens to be acting, for this one render (see `peekPlayer`)
* — INCLUDING A LOOK AT YOUR OWN BOARD, which is why this returns rather than falling through.
*
* Falling through sent "show me mine" to the actor logic below, so the one player who could not
* reach their own Office Area was the player waiting on everybody else (playtest, 2026-09-16: Tom,
* hanging about while the board followed Jesse). Null IS your own district: it is what the caller
* draws from `f.cells` when nobody else is being watched.
*/
if (peekPlayer !== null) {
return peekPlayer === f.viewer ? null : (pub.districts.find((d) => d.player === peekPlayer) ?? null);
}
let player: PlayerIndex | null = f.actor;
if (stepQueue.busy()) {
@@ -1749,8 +1757,17 @@ function render(): void {
$('depts').classList.remove('aiming');
}
// -- making up a train: the Division Yard chip that shows the car IS the button.
if (menu.makeUp) {
/**
* -- making up a train: the Division Yard chip that shows the car IS the button.
*
* NOT WHILE THE BOARD IS BEHIND (playtest, 2026-09-16). `renderActions` puts the action list away
* while the queue is catching up — a move offered against a position that has already moved on is
* a move made blind — but this wiring sat outside that guard, so the yard chips stayed lit and
* clickable. Jesse clicked one during a bot's make-up, a coach left the yard, and the train ended
* up with three cars: he had submitted a real intent against a board he could not see. The chips
* follow the same rule as every other control now.
*/
if (menu.makeUp && !stepQueue.busy()) {
for (const el of Array.from($('divyard').querySelectorAll('[data-car]'))) {
const node = el as HTMLElement;
const car = menu.makeUp!.cars.find(
@@ -1791,7 +1808,16 @@ function render(): void {
*/
const heldBack = stepQueue.pendingLines();
const allLines = heldBack > 0 ? session.lines().slice(0, -heldBack) : session.lines();
const shownLines = allLines.slice(-60);
/**
* NINETY LINES, IN THE SAME BOX (Jesse, 2026-09-16: "increase to 90, keep the box the same size").
*
* The panel scrolls already, so a longer tail costs no screen and lets a player scroll further
* back through a Stage they were not watching. It is capped at all only because the list is
* rebuilt on every render; the log itself is uncapped in memory, so the number is a display
* choice rather than a limit. The BOX stays 230px on purpose — growing it would push the newest
* line, the one being read, further from where the eye already is.
*/
const shownLines = allLines.slice(-90);
/**
* WHERE THE GAME BEGAN. In a multiplayer game the bots move the instant the host presses Start, so
* by the time the board paints the log already has several turns in it and nothing says which of
@@ -1914,16 +1940,26 @@ function renderUndo(): void {
* left, and the moment the Division Yard empties a whole pile comes back at once.
*/
function renderYards(f: Frame): void {
$('divyard').innerHTML = yardHtml(f.yards.division);
$('clsyard').innerHTML = yardHtml(f.yards.classification);
$('divtot').textContent = `${f.yards.divisionTotal} cars`;
$('clstot').textContent = `${f.yards.classificationTotal} cars`;
/**
* THE YARDS BELONG TO THE BOARD ON SCREEN, NOT TO THE GAME (playtest, 2026-09-16).
*
* They were drawn from the live Frame while everything around them was held back, so a player
* five moves behind read yard counts from a future they had not been shown — "the yards may not
* be in sync with the turns behind", and they were not. Same rule as the turn chart: while the
* queue is behind, this is the shown board's yards; at rest the two are the same object.
*/
const pub = stepQueue.current();
const yards = pub && stepQueue.busy() ? pub.yards : f.yards;
$('divyard').innerHTML = yardHtml(yards.division);
$('clsyard').innerHTML = yardHtml(yards.classification);
$('divtot').textContent = `${yards.divisionTotal} cars`;
$('clstot').textContent = `${yards.classificationTotal} cars`;
// The one thing worth calling out: the yard about to turn over.
const bare = f.yards.divisionTotal === 0;
const bare = yards.divisionTotal === 0;
$('divyard').classList.toggle('bare', bare);
$('yardnote').textContent = bare
? `The Division Yard is bare — the ${f.yards.classificationTotal} cars in Classification return to it now.`
? `The Division Yard is bare — the ${yards.classificationTotal} cars in Classification return to it now.`
: 'loaded / empty. Classification returns to the Division Yard only when the Division Yard is bare.';
}
@@ -1996,17 +2032,37 @@ function renderDistrict(f: Frame): void {
* from another person, and so the one that must never be pasted into markup.
*/
const peek = $('districtpeek');
const others = f.players.filter((p) => p.index !== f.viewer);
/**
* EVERY SEAT, YOURS INCLUDED, IN MAP ORDER (playtest, 2026-09-16).
*
* Two faults, both reported from one game. There was no button for your OWN district, so a player
* waiting on everybody else could look at any board except the one they were playing — and the
* buttons came out in player order, which is the order people joined, not the order they sit.
*
* Sorted by SEAT, which is west-to-east along the Division exactly as the map draws it, so the row
* reads left to right the way the railroad does. Seat is not player index and must not be assumed
* to be: under Employee Rotation the seating moves, and because this sorts the Frame's own `seat`
* on every render, the buttons rotate with the players rather than having to be told.
*/
const seats = [...f.players].sort((a, b) => a.seat - b.seat);
peek.innerHTML = '';
peek.hidden = others.length === 0;
for (const p of others) {
peek.hidden = seats.length < 2;
const watchedNow = watchedDistrict(f);
for (const p of seats) {
const b = document.createElement('button');
b.type = 'button';
b.className = 'ghost';
// NAMES GO IN AS TEXT, NEVER MARKUP: a display name is whatever somebody typed in the lobby.
b.textContent = p.name;
b.title =
`Look at ${p.name}'s Office Area. It is read-only, and it reverts as soon as the board next ` +
`redraws — press Pause first if you want to study it.`;
const isYou = p.index === f.viewer;
// Which board is up right now — yours when nothing is being watched, otherwise the watched one.
const showing = watchedNow === null ? f.viewer : watchedNow.player;
// `.seg button[aria-pressed="true"]` already lights the current one — no extra class to style.
b.setAttribute('aria-pressed', String(p.index === showing));
b.title = isYou
? 'Back to your own Office Area.'
: `Look at ${p.name}'s Office Area. It is read-only, and it reverts as soon as the board next ` +
`redraws — press Pause first if you want to study it.`;
b.onclick = () => {
peekPlayer = p.index;
render();
@@ -2407,9 +2463,8 @@ function renderActions(
* on, and never again. "Click a car" then reads as "build this train", so a player adds one
* and the turn moves on with no explanation (Jesse, playtest 2026-09-16).
*/
`<b>One car each:</b> you add a single car, then the round passes to the next player — ` +
`starting from the Superintendent and working left, coming round again until the train is ` +
`full or the Division Yard holds nothing it can take.`
`<b>Each player adds one car at a time</b>, starting from the Superintendent and working ` +
`eastward, repeating until the train is full or the Division Yard holds nothing it can take.`
: menu.makeUp.pass !== null
? 'The Division Yard is bare, so there is nothing to add. Send the train out as it stands.'
: 'Nothing in the Division Yard may join this train, and passing is not allowed while the yard holds cars.') +