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:
co-authored by
Claude Opus 5
parent
312e0301e0
commit
02289e94b8
+134
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user