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
+134
View File
@@ -19,6 +19,140 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
---
## 0.8.0 — 2026-09-09
**Watching the table.** TODO #13, #15 and #18, which is Gitea#20 steps 2-4 pointed at a seated
player's own screen. Jesse, 2026-08-29: *"It's not fun to do my turn and have magic happen in the
background and then have to figure out what others did."* And 2026-09-09, on what he most wants to
see: *"I definitely want to watch other players struggle with the switching exercises … I don't
think reading the switching in the log will be anywhere nearly as interesting as watching the trains
actually move on the board."*
The release was scoped in conversation: **0.8.0 is this, 0.8.1 is the seatless board page, 0.9.0 is
the Jitsi publisher** — *"13 is the key. Watching on a TV is the bonus."* The design is
`docs/plans/jitsi-common-board.md` § v0.8.0.
### The correction that set the scope
`Frame.cells` is ONE district — the viewer's own, built from `areaOf(s, viewer)`. So a step stream
alone does not answer #13: the data would arrive with nowhere to be drawn. **Rendering a district
you do not own is the feature**, not part of the seatless page it had been filed under. Nothing
needed to change in `officeSvg` to do it — it takes board data and has never wanted a private
viewer, which is why `PublicDistrict` renders as-is.
### One hook, not two, and replay inert for free
The design anticipated wiring a collector into `GameSession.intent()` and `driveBots()` separately,
with solitaire doing its own thing. It needs neither: `src/server/session.ts` imports `submit` from
`src/web/game.ts`, so solitaire, live multiplayer and every bot turn already funnel through one
function. That is also what makes this a special case of multiplayer rather than a second
implementation.
And `fromSave`/`fromMultiplayerSave` rebuild a game with `applyIntent` + `record` + `drain` rather
than `submit`, so a resumed server does not re-emit a whole game as steps. The plan expected that to
need a guard. It needs none — but the property is load-bearing rather than lucky, so it is pinned by
test.
### Pacing, decided by measurement
The obvious scheme is a time budget divided by the queue length. Measured against real games it does
exactly the wrong thing: 44 of a 307-intent game are `draw.end` and 60 are `loadUnload.end`, while
the thing worth watching is rare and clustered — two of the three published replays contain no
`switch.move` at all, and the third has bursts of **14, 6, 6 and 6**. Six is the engine's own cap per
crew, which `trayMoved` says out loud ("N of 6 Moves left"). A uniform budget spends the player's
attention on bookkeeping and rushes the switching.
So dwell is assigned **by kind**: switching 1000ms (Jesse: *"start at 1s and tune down"*), an
ordinary action 250ms, a phase 600ms, bookkeeping zero. Three tuning levels, because the committed
table needs a web rebuild and in the `.s9pk` that is a release: the table, a per-viewer `pace`
multiplier in `Settings` where **0 turns it off**, and a `?pace=` URL parameter for handing two
playtesters different speeds. Deliberately **not** in game-creation settings — dwell is presentation,
not a rule, and a `GameConfig` rides along in saves and replays.
**Cost, measured:** about **4.4 minutes of animation across a whole 6-day game**, of which phases are
now the largest slice and therefore the first dial to turn.
### TODO #18 needed a stepped pump, not a delay
`pump()` runs every automatic phase between one click and the next and `drain()` records the whole
batch, so New Train, the Mainline and the shift change were never drawn at all. Folding them into the
triggering intent's step reproduced exactly that. `submit()` now steps `advance()` one call at a time
and collects per phase; `drain()` is untouched, because replay, undo and `fromSave` all use it and
the inertness above depends on their staying off that path.
**Two obvious rules for what earns a beat were both wrong, and both are now pinned by test.** "No
narration, no dwell" looked right and silently killed #18 — a phase can move trains without saying
anything. "Anything that changed the board" beat on every turn hand-off, and `submit()` steps
`advance()` about 4.6 times per intent, which came to a quarter of an hour a game. The rule is that
the **clock turning over** earns the beat.
### The counter, which is Jesse's design
*"If I saw the counter as I'm watching the board go 17, 16, 15 … and I got impatient, I could just
click a button and have it skip all the rest."* One row rather than three additions — the countdown,
#15's caption naming the action being shown, and Skip. It counts only the steps that will actually
**dwell**: with bookkeeping at zero, a backlog of 17 where 12 are `*.end` would read "17", plummet to
5 instantly and then crawl, which is not a countdown anyone can act on. Skip costs the animation and
never the information — every line is already in the History panel.
This also settled the question the design had left open: an "it's your turn" that arrives while the
board is still catching up is confusing, and showing the lag beats both alternatives (holding the
turn indicator back, or saying nothing).
### Switching logged unattributed, and now names its train
`record()` attributes a line only when the event carries `player`. **`trayMoved`, `carsCoupled`,
`carsDropped` and `consistSorted` were the only events in their class that did not** — so a switching
turn read as an attributed bracket around anonymous contents: "Player Alice chose to switch / CREW
moved (1,2) → (1,3) / Player Alice finished Local Operations". Fixed with the feature that reads
those lines rather than filed. Two texts were reworded to compose with the prefix, because
`uncapitalise` deliberately protects acronyms and "Player Alice CREW moved" is what it would
otherwise have produced. On Jesse's ask the move now names its train — "moved Train 3 (1,2) → (1,3)"
— using the existing `trainName`, since a second way of naming a train is the drift this codebase
avoids.
Saves are unaffected: a save is a seed and a list of intents, and events are derived.
### The delta had to become a true partial
Steps carry a `PublicFrame` delta. The first version spread `...next` and nulled only the board
fields, so every step shipped all 35 top-level properties even when the only change was whose turn it
was. Once #18 gave phases their own steps most steps became exactly that, and a full game cost
**19.4 MB, of which 16.7 MB was silent steps at ~11 KB each**. As a true partial — only changed
fields, only changed districts — the same game is **8.7 MB**. Districts are keyed by seat rather than
compared as one array, which alone saves 22%: one intent changes one district, and a whole-array
compare resends every other player's board every step.
### The redaction net grew to cover the steps, and grew two exemptions
The steps are folded into `everythingSeatSees`, so every existing case covers them — the blind draw,
the pending decision, Employee Rotation before and after the seating moves, the reconnect, the
played-out game. Doing that surfaced two false positives in the name-based heuristic, **neither
caused by this feature**, and the distinction they forced is worth keeping: **a card NAME is
circumstantial, a card ID is proof.** Ids are searched everywhere. Names are not searched in two
places entitled to carry them — lines naming a **face-up pile** (§2.6: a Department is public, so
"Ann discarded Train 6 face-up on Department 3" is the record working, and it stays in the log after
she takes it back), and the **accumulated step frames**, which record what was public over time
rather than the position now.
The harness was also passing `g.log` into `snapshot()` for the Frame's own lines, which **production
has not done since #97**; now `[]`, matching `frameFor()`.
**Verified by injecting the v0.7.9.2 blind-draw leak and confirming the net still fails** — both the
dedicated test and, independently, the new step coverage. A relaxed safety test that has not been
shown to still bite is not a safety test.
### What is not verified
The mechanism is proven end to end **server-side**: a real server, a real 3-seat game with two bots,
and 28 steps read off a live SSE stream with dense sequence numbers and a 13-step bot burst intact.
The page is proven not to throw — `drainIntoQueue`, `renderWatching` and `watchedDistrict` all run
under the existing DOM-stub tests. **Nobody has watched it in a browser.** The district switching to a
bot's board, the row appearing, and Skip are unexercised, because the stub has no
`requestAnimationFrame` and the page degrades to un-animated without one.
---
## 0.7.9.8 — 2026-09-07
Housekeeping before v0.8.0 — the answer to "anything else that should be looked at first", which