From 02289e94b81d0c5ae05e2634737a71e7b2d7ccd7 Mon Sep 17 00:00:00 2001 From: "Jesse.Markowitz" Date: Wed, 9 Sep 2026 15:31:46 -0400 Subject: [PATCH] =?UTF-8?q?v0.8.0=20=E2=80=94=20the=20board=20replays=20wh?= =?UTF-8?q?at=20everyone=20else=20did,=20instead=20of=20arriving=20rearran?= =?UTF-8?q?ged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6 --- CHANGELOG.md | 134 +++++++++ README.md | 13 +- TODO.md | 78 ++++-- docs/plans/jitsi-common-board.md | 451 +++++++++++++++++++++++++++++-- package.json | 2 +- src/engine/apply.ts | 5 +- src/engine/events.ts | 7 +- src/server/session.ts | 60 +++- src/sim/display-step.ts | 134 +++++++++ src/sim/narrate.ts | 4 +- src/sim/pacing.ts | 167 ++++++++++++ src/sim/public-delta.ts | 132 +++++++++ src/web/game.ts | 78 +++++- src/web/main.ts | 320 ++++++++++++++++++---- src/web/play.html | 23 +- src/web/session.ts | 71 ++++- src/web/step-queue.ts | 120 ++++++++ test/apply.test.ts | 8 +- test/multiplayer.test.ts | 3 +- test/pacing.test.ts | 124 +++++++++ test/public-delta.test.ts | 186 +++++++++++++ test/redaction.test.ts | 114 +++++++- test/replay.test.ts | 65 ++++- test/step-queue.test.ts | 204 ++++++++++++++ test/watchable.test.ts | 311 +++++++++++++++++++++ 25 files changed, 2669 insertions(+), 145 deletions(-) create mode 100644 src/sim/display-step.ts create mode 100644 src/sim/pacing.ts create mode 100644 src/sim/public-delta.ts create mode 100644 src/web/step-queue.ts create mode 100644 test/pacing.test.ts create mode 100644 test/public-delta.test.ts create mode 100644 test/step-queue.test.ts create mode 100644 test/watchable.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 89b0c72..03736bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,140 @@ page as `v0.1.0 · · `, 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 diff --git a/README.md b/README.md index 8f735ef..6bd7ade 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,19 @@ deliberately no longer names one: it went stale for six releases. reload; anybody may leave and the host may clear a chair; and the four transient signals that make a game feel alive — sound, the timetable flash, an announcement, the badge on the card you just drew — reach a remote client, which they did not before v0.7.0. What is still open is in `TODO.md` - under Multiplayer — chiefly that **a player cannot see what the others did**, and that a lost + under Multiplayer — chiefly that a lost session token still locks someone out of a running game from a genuinely fresh browser. +- **Watching the table — v0.8.0.** Every accepted move, and every automatic phase that does + anything, becomes an ordered **presentation step**: the board replays other people's turns instead + of arriving already rearranged. This is what closes "a player cannot see what the others did", + which stood open through v0.7.x. A bot's whole switching turn used to land in one push, because + `driveBots()` plays it out before the push goes back; 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. Dwell is assigned **by kind** — a switching move holds the screen, turn bookkeeping + costs nothing — and is tunable per viewer without a rebuild. Solitaire runs the same path, which is + where its automatic phases finally get a visible beat. + **Not yet checked in a browser:** the mechanism is proven server-side against a live SSE stream and + the page is proven not to throw, but nobody has watched a bot switch on screen. - **Not built** — the opponent-directed cards (the Action and Space-use categories, held out of every deck until they have an implementation, along with the defensive cards whose only purpose is to answer them), and real audio. No screen offers a control for the opponent cards any more: the diff --git a/TODO.md b/TODO.md index 13a0d02..2fcc149 100644 --- a/TODO.md +++ b/TODO.md @@ -128,32 +128,72 @@ specific paths below have not been exercised at a table. **More testing is plann ## The common board, and watching play happen — Gitea#20 -**This is v0.8.0.** One shared, seatless display of the public game, usable on a TV or in OBS on its -own and publishable into the table's Jitsi meeting. The plan is `docs/plans/jitsi-common-board.md`, -seven steps, of which 1-4 are the useful release and 5-7 are the Jitsi publisher. +One shared, seatless display of the public game, usable on a TV or in OBS on its own and publishable +into the table's Jitsi meeting. The plan is `docs/plans/jitsi-common-board.md`, seven steps. + +**THE RELEASE SPLIT, settled with Jesse 2026-09-09. Jesse: "13 is the key. Watching on a TV is the +bonus."** + +- **v0.8.0 — #13, #15 and #18: the watchable table.** The step collector, steps on the `Session` + interface, **foreign-district rendering**, the client animation queue, pacing by kind, and the + behind-counter. **The design is the plan's § v0.8.0**, which supersedes the parts of steps 2-4 it + covers. Needs no HTTP work at all. +- **v0.8.1 — the seatless board page.** `display.json`, `viewToken`, `/api/display/stream`, + `/display.html`, an all-districts layout: most of step 2 and step 3 without its canvas. Cheap once + 0.8.0 lands, and nothing in it moves #13 forward. +- **v0.9.0 — the Jitsi publisher.** Steps 5-7 plus step 3's canvas pipeline. Held off deliberately: + it needs Chromium in the image (several hundred MB onto a 63 MB `.s9pk`) and measurement on + `phoenix.local`, and the only self-hosted Jitsi available needs an authenticated moderator to open + a room, so "waiting for moderator" is the ordinary path here rather than an edge case. + +**The correction that set that split:** `Frame.cells` is ONE district — the viewer's own +(`view.ts:510`, from `areaOf(s, viewer)`), exactly as **Reference · #13** already said. 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 core of 0.8.0, not part of the seatless page. Two other decisions +taken with it: **no WebSocket and no new runtime dependency** (SSE down + POST up, the pattern +`server/http.ts` already uses), and `protocolVersion` on the wire in 0.8.0. **Step 1 is BUILT** — v0.7.9.2 through v0.7.9.5 (#91, #92, #95, #97), with one item struck off -rather than implemented (#103). **The plan was reconciled against the code in v0.7.9.8** and now says -which of its "current code findings" are history: it had drifted badly enough to send the next reader -fixing things twice. Steps 2-7 were never implemented and their findings have NOT been re-verified — -check each before building on it. See **Reference · #103**. Items that look -like screen polish live here because they need step 4's ordered presentation mechanism and nothing -cheaper. +rather than implemented (#103). **The plan was reconciled against the code in v0.7.9.8** and again +on 2026-09-09, and now says which of its "current code findings" are history: it had drifted badly +enough to send the next reader fixing things twice. Steps 2-7 were never implemented; step 4's +findings were re-verified 2026-09-09 and steps 5-7's were NOT — check each before building on it. +See **Done · 103**. Items that look like screen polish live here because they need step 4's ordered +presentation mechanism and nothing cheaper. + +**The design is settled — the plan's § v0.8.0 is the authority.** In outline: public steps animate +the board while the existing private Push supplies hand, menu and objective (so there is no new +redaction surface); the collector is a shared `sim/` module both `LocalSession.submit()` and +`GameSession.submit()` call, so solitaire and multiplayer run one code path; dwell is assigned **by +kind** with switching protected at 1s and bookkeeping at zero; and a `[N behind] … [Skip]` row shows +the lag, carries #15's caption, and never blocks input. Two measurements that decided it: a +switching turn runs to the engine's cap of **6 moves** (bursts of 14, 6, 6, 6 in `seed-1917398`), +and dwell-by-kind costs ~40s of animation across a 60-stage game against 3.6 minutes for a flat +700ms. - [ ] **#13** — I cannot see what the other players did — bots included. **Settled 2026-08-29 as the harder reading**: not log legibility but the ordered, per-action presentation of everyone else's turns. Jesse: "It's not fun to do my turn and have magic happen in the background." This is - Gitea#20 step 4 pointed at a player's own screen. See **Reference · #13**. + Gitea#20 step 4 pointed at a player's own screen. **DESIGNED 2026-09-09 — the plan's § v0.8.0.** + The answer is foreign-district rendering with focus following the actor; without it a step + stream has nowhere to draw, because `Frame.cells` is the viewer's district alone. See + **Reference · #13**. - [ ] **#15** — A "most recent action" line under the status block. The text already exists and is already correct — this is placement, not content. **Decide with #13**: in solitaire "most recent" is the right unit; in multiplayer what you missed is everything that happened while you - were WAITING. See **Reference · #15**. + were WAITING. **DESIGNED 2026-09-09 — it is the caption in the `[N behind] … [Skip]` row, not a + separate line.** The queue IS "everything that happened while you were waiting", which is the + unit this entry could not choose. See **Reference · #15**. - [ ] **#18** — Give every phase a visible beat. Not a timing problem — `pump` runs every automatic phase before the page renders once, so they are never drawn at all. **A `sleep` fixes nothing; it needs the async stepped pump that Gitea#20 step 4 specifies**, which is why it lives here - rather than under The screen. See **Reference · #18**. + rather than under The screen. **DESIGNED 2026-09-09 — it is a dwell setting on the shared queue, + not a feature.** Phases where nothing happened dwell at ZERO (Jesse: "if nothing happens during + a phase then we shouldn't lose time to it"); a flat second per phase is rejected on the same + arithmetic this entry already worked out. Solitaire gets it through `LocalSession`, the same + path multiplayer gets #13 through. See **Reference · #18**. - [ ] **#75** — Let the game join a call and talk to the table — the chat, audio and nudge half of the idea Gitea#20 took the visual half of. Long-term. See **Reference · #75**. @@ -1761,7 +1801,7 @@ where it belongs, and it is still open. Closed items, kept because several are the only record of a ruling or a lesson. Newest first within each group. -### Shipped through v0.7.9.4, from the queue +### Shipped through v0.7.9.8, from the queue Closed items, newest first. Kept because several of them are the only record of a ruling or a lesson; the numbers stay so cross-references above and below still resolve. @@ -1996,11 +2036,13 @@ the numbers stay so cross-references above and below still resolve. `# fail 0`. `pretest` is `tsc --noEmit && node scripts/build-web.ts` now, and the same planted error exits 1 with the tests never running. - **Why it mattered THIS week rather than generally.** v0.8.0 is steps 2-7 of the common board — - a display stream, credentials, persistence, and a Chromium supervisor — which is almost - entirely `src/server/` and is exactly the half the test command could not see. Found while - answering "anything else before 0.8.0", which is the only reason it was found at all: nothing - about a green suite would ever have said so. + **Why it mattered THIS week rather than generally.** The next release is steps 2-4 of the + common board — a display stream, credentials, persistence and the display-step collector, which + is almost entirely `src/server/` and is exactly the half the test command could not see. (The + Chromium supervisor was in this list when the entry was written; steps 5-7 became v0.9.0 on + 2026-09-09, and the point stands without it.) Found while answering "anything else before + 0.8.0", which is the only reason it was found at all: nothing about a green suite would ever + have said so. 103. ~~**The common-board plan had drifted from the code it is the source for.**~~ — done 2026-09-07 in v0.7.9.8. `docs/plans/jitsi-common-board.md` was written 2026-08-27, still said "No diff --git a/docs/plans/jitsi-common-board.md b/docs/plans/jitsi-common-board.md index 11167e0..cda8721 100644 --- a/docs/plans/jitsi-common-board.md +++ b/docs/plans/jitsi-common-board.md @@ -1,11 +1,53 @@ # Station Master Jitsi Common Board Implementation Plan -**Status (2026-09-07):** **STEP 1 IS BUILT AND SHIPPED. Steps 2-7 are unimplemented.** +**Status (2026-09-09):** **STEP 1 IS BUILT AND SHIPPED. Steps 2-7 are unimplemented.** Step 1 landed across four releases rather than one — v0.7.9.2 (the two narration leaks), v0.7.9.4 (the projection helpers and the redaction net), v0.7.9.5 (the narration path), and v0.7.9.8 (this reconciliation). One of its items is struck off rather than built; see § Public game projection. +## THE RELEASE SPLIT — read this before picking up any step + +Settled with Jesse 2026-09-09. The document below was written as one seven-step delivery and its +ordering still reads that way, so this section and § v0.8.0 are the authority on what belongs where. + +| Release | What | Why here | +| --- | --- | --- | +| **v0.8.0** | **TODO #13/#15/#18 — the watchable table.** The step collector, steps on the `Session` interface, foreign-district rendering, the client animation queue, pacing, and the behind-counter. § v0.8.0 is the design. | #13 is the point. *"Watching on a TV is the bonus"* — Jesse, 2026-09-09. | +| **v0.8.1** | **The seatless board page.** `display.json`, `viewToken`, `/api/display/stream`, `/display.html`, an all-districts layout. Most of step 2, and step 3 without its canvas. | Cheap once 0.8.0's foreign-district rendering exists; nothing in it moves #13 forward. | +| **v0.9.0** | **The Jitsi publisher.** Steps 5-7, plus step 3's canvas capture pipeline. | Needs Chromium in the image and hardware measurement. Held off deliberately. | + +**What 0.8.0 is FOR, in Jesse's words (TODO #13, 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 watch: *"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."* + +### Decisions taken 2026-09-09 that the text below has NOT been rewritten around + +- **`Frame.cells` is ONE district — the viewer's own** (`view.ts:510`, from `areaOf(s, viewer)`). So a + step stream alone does not answer #13: the data would arrive and have nowhere to be drawn. + **Rendering a district you do not own is the core of 0.8.0, not part of the seatless page.** This + is the correction that set the split above; it was mis-assigned until 2026-09-09. +- **No WebSocket, and no new runtime dependency.** The control channel exists only to join the + supervisor to the headless agent — a handful of messages per publisher lifetime, on loopback. + `package.json` has no `dependencies` key and the wrapper's runtime image copies only + `package.json` and `src/` with no `node_modules`, so adding `ws` changes the deployment model + rather than adding a dependency. Use SSE down + POST up, the pattern `src/server/http.ts` already + implements for players (`/api/stream` + `/api/intent`). The proven Jitsi code ports either way: + `engine/communications/CommunicationsClient.ts` in the sibling repo references the control layer + in two comments and nothing else. **0.9.0 work.** +- **`protocolVersion` is added in 0.8.0.** See § Public game projection. +- **0.8.0 needs no HTTP work at all.** The `startServer()` refactor and the project's first HTTP test + harness existed to test `/api/display/stream`, which is now 0.8.1. `Push.steps` is built in + `session.ts`, and `broadcastGame()` forwards whatever push it is handed, so `http.ts` does not + change and the tests land in the existing `test/server/session.test.ts`. +- **Solitaire is a special case of multiplayer, not a second implementation** — Jesse's stated design + direction, to minimize rule and implementation drift. `src/web/session.ts` already draws that seam: + *"the page … does not care whether the rules are being applied a function call away or across a + network."* Every mechanism below hangs off `Session`, with `LocalSession` and `RemoteSession` both + feeding it. Solitaire therefore gets #18 through the same code path multiplayer gets #13 through. + **This document has drifted from the code and is no longer the authority on what exists.** It was written on 2026-08-27 against the code of that date, and the "Current code findings" under each step describe faults that were then real — several are now fixed, and reading them as present tense will @@ -13,6 +55,262 @@ send you to fix things twice. Where a step is marked built, `src/sim/view.ts`, ` tests named in TODO.md are the authority. Steps 2-7 were never implemented and their findings have NOT been re-verified against the current code; check each before building on it. +## v0.8.0 — The watchable table + +**This section supersedes the parts of steps 2-4 it covers.** Where it and a step below disagree, +this wins; the steps keep the material that is still 0.8.1/0.9.0 work. Designed with Jesse +2026-09-09 in conversation; every measurement quoted was taken from the code and the published +replays that day. + +### The shape + +One mechanism, four consumers, no branch between solitaire and multiplayer: + +``` +submit() ──► collector (sim/) ──► DisplayStep + │ + ┌───────────────────┴───────────────────┐ + LocalSession.steps() Push.steps ──► RemoteSession.steps() + └───────────────────┬───────────────────┘ + ▼ + client animation queue (one impl) + ▼ + board render · caption · behind-counter · skip +``` + +### 1. The collector + +> **BUILT 2026-09-09** — `src/sim/display-step.ts`, `test/watchable.test.ts`. Two things below were +> wrong in a way worth recording, because both made the job smaller. + +**ONE HOOK, NOT TWO.** This section said to wire two call sites in `GameSession` and let +`LocalSession` do its own thing. It does not need to: **`src/server/session.ts` imports `submit` +from `src/web/game.ts`**, so solitaire, live multiplayer and every bot turn already funnel through +one function. Collecting inside `submit()` covers all three, and *that* is what makes solitaire a +special case of multiplayer here rather than a parallel implementation. + +**REPLAY IS INERT FOR FREE.** `fromSave` and `fromMultiplayerSave` rebuild a game with `applyIntent` ++ `record` + `drain` directly rather than through `submit`, so a resumed server does not re-emit the +whole game as steps. No guard is needed. But the property is load-bearing rather than lucky — move a +replay path onto `submit()` and it silently becomes the #97-class bug this section feared — so +`test/watchable.test.ts` pins it. + +- One step per accepted intent, **including** automatic work drained behind it. Never one step per + `GameEvent` — an intent drains `pump()` work and the event list is not a complete reducer. +- **Narration's high-water mark is taken inside `submit()`**, which brackets `record` and `drain` and + is therefore the only place that knows what one intent said. Not `sentLines`: that is per-seat and + is *mutated* by `linesSince()` as a side effect of building a push. +- Capture the frame immediately; never retain a mutable `GameState` reference for later projection, + or every retained reference resolves to the final state. +- Accumulated on `Game` beside `log`, `cues` and `announced` and drained by `takeSteps()` the way + `takeMoment()` drains the rest — the established convention for "the model accumulated something, + the view takes it". `pushesForAll()` drains ONCE per broadcast, not per seat. + +### 2. Delivery — on the `Session` interface + +`Session` gains steps. `LocalSession` emits them from its own `submit()`; `RemoteSession` reads them +off `Push.steps`. The page consumes one queue and cannot tell which it has. + +`Push` gains `steps?: DisplayStep[]`, following the **`presence` precedent** — `Push.frame` is +already optional, and presence went in as a field rather than a second SSE event type for the reason +recorded at `http.ts:210`: *"one message shape for the client to parse."* + +`http.ts` does not change. `broadcastGame()` forwards whatever push `session.ts` builds. + +### 3. What a step carries, and what animates + +**Public steps animate the board; the private `Frame` supplies hand, menu and objective.** + +Your hand never changes because somebody else moved. What can change splits cleanly: revenue is +already in `PublicFrame` (`players` carries it) so it animates; `menu` and `blocked` are recomputed +and arrive with the final coalesced Push, as today. So a step carries a `PublicFrame` delta and +**adds no new redaction surface** — it reuses the projection `test/redaction.test.ts` already guards. + +Rejected: emitting N per-seat redacted `Frame`s per intent. It multiplies both the projection work +and the redaction test surface, and buys nothing — a seated step would be a `Frame`, which is +redacted per seat, so it could not reuse the public delta anyway. + +**Delta the districts per seat, not as one array.** Measured 2026-09-09 over a 300-step 4-player +game (`sim/public-delta.ts` now built, `test/public-delta.test.ts` pins reconstruction): a full +frame every step is **20.4 KB/step, 6.0 MB** over the game; a `frame-delta`-style whole-array +compare is **3.0 MB** (51%); keying by seat is **2.4 MB** (40%), saving a further **657 KB, 22%** +over the whole-array form. `sim/frame-delta.ts` hardcodes +`BOARD_KEYS = ['cells','facilities','division']` against `Frame`; `PublicFrame` has no top-level +`cells`/`facilities` — they live inside `districts[]`, one per seat, which is where nearly all the +bytes are. One accepted intent changes one district, so a whole-array comparison resends every other +player's board on every step. Keep `deltaFrame`/`applyDelta`'s "null means unchanged" convention; +replace the key set. + +**`protocolVersion` goes on the ENVELOPE, not on `PublicFrame`.** The original sketch put it inside +the frame; it does not belong there. `PublicFrame`'s property list is an allow-list that +`test/redaction.test.ts` enumerates, so a transport concern living in it would have to be declared +public *game state*, which it is not. The step is the message; the message carries the version. +Built as `DISPLAY_PROTOCOL_VERSION` in `sim/display-step.ts`. + +### 4. Foreign-district rendering — THIS IS #13 + +`Frame.cells` is one district, the viewer's own. A step stream without this is data with nowhere to +go, so this is the feature rather than a supporting part of it. + +- Render any seat's district from `PublicDistrict`. +- **Focus follows the actor** — the district panel shows whoever is acting, theirs while they switch + and yours when it is your turn. Same rule the seatless board will use in 0.8.1: acting player's + seat, else most recent actor's seat, else seat zero. +- **Resolve owner from seat on every frame**, so Employee Rotation relabels a district in place + rather than moving it. +- `Frame` already carries `whereFrom` — *"Origin of a Move, so the crew's journey is visible rather + than a chip teleporting"* — which is the same idea for the viewer's own moves. Extend it, don't + invent a second one. + +### 5. Pacing — dwell by kind + +**Measured 2026-09-09, and the measurements decide the model.** From `public/replays/`: ~60 stages +per game and ~5 intents per player per stage, so a 4-player table generates **~15 other-player steps +per stage**. Burst sizes inside one switching turn, in the switching-heavy seed +(`seed-1917398`): **14, 6, 6, 6** — and `trayMoved`'s own narration says *"N of **6** Moves left"*, +so six is the engine's cap per crew. Two of the three published replays contain **zero** +`switch.move`: bot switching is clustered, not spread. + +So a uniform budget spread over the queue does exactly the wrong thing — it steals time from the +6-move switching burst to spend on the 44 `draw.end` and 60 `loadUnload.end`. **Assign dwell by kind +and let the total fall out.** + +`sim/pacing.ts` — shared, so the 0.8.1 seatless page paces identically and the TV and the play screen +never disagree about how fast the game looks: + +```ts +export type StepKind = 'switching' | 'action' | 'bookkeeping'; + +/** THE TUNING TABLE. Dwell in ms per kind. Start generous; tune down by playing. */ +export const DWELL: Record = { + switching: 1000, // switch.move / dropCars / sortConsist / maneuver.* + action: 250, // draw.from* / card.* / newTrain.placeCar / porter.* / laborer.* + bookkeeping: 0, // *.end, and any phase where nothing happened +}; +``` + +Jesse 2026-09-09: **start switching at 1s and tune down**, and *"make sure that the tuning parameters +for the delays are easy to set."* Three levels, deliberately: + +| Level | Where | Reach | +| --- | --- | --- | +| Committed default | the table above | needs a web rebuild — in the `.s9pk`, a release | +| Live per-viewer | `pace` in `Settings` (`localStorage`), a multiplier; **`0` = off** | no rebuild | +| Per-session | `?pace=` URL parameter | matches the existing `?seed=` convention; hand two testers different links | + +**Not in game-creation settings.** Jesse, 2026-09-09: *"for now, they should not be in the game +creation settings, but we may want to put them there later."* Correct on its own terms — dwell is +presentation, not a rule, and `config` rides along in saves and replays. `Settings` already carries +the argument for this: *"A save is the seed plus the intents and has to stay portable; none of this +belongs in it, and in a multiplayer game two players may reasonably want these set differently."* +The migration path is cheap: because the table is shared and the override is one scalar, moving it to +game config later means adding a config field that supplies the multiplier's default. The table, the +classification and the queue do not change. + +**Pacing is client-side only.** The server emits steps as fast as it likes, which is what keeps +Gitea#20's *"do not slow the authoritative game"* true. + +**Arithmetic, because it is the reassuring part:** ~16s of switching plus ~25s of one-shots ≈ **40s +of animation across a whole 60-stage game**, against 3.6 minutes for a uniform 700ms. Tiering gives +*better* switching visibility for a fifth of the total time. One complete switching exercise is +6 × 1s = 6s to watch. + +### 6. The behind-counter, the caption, and skip + +**Jesse's design, 2026-09-09**, and it resolves the "it's your turn but the board is stale" question +that had two unattractive answers before it (hold the turn indicator, or show both silently): + +> *"If I saw the counter as I'm watching the board go 17, 16, 15 … and I got impatient, and I could +> just click a button and have it skip all the rest."* + +One row, not three additions — the slot between `#turnchart` and `
` already holds +`#phasenote`, `#announce` and `#presence`, and the palette is spoken for (violet = where you are, +amber = clickable, green/red = good/bad): + +``` +[13 behind] Player Alice moved Train 12 (1,2) → (1,3) via (1,1) [Skip] +``` + +- The counter **is** `queue.length` — client-side, derived, **zero protocol impact**. +- **Count only steps that will dwell.** With bookkeeping at 0ms, a backlog of 17 where 12 are `*.end` + would read "17", plummet to 5 instantly, then crawl. Thirteen dwelling steps means thirteen things + you are going to watch. +- The caption is **#15**. TODO's Reference · #15 asks what the unit should be and concludes that in + multiplayer it is *"everything that happened while you were WAITING"* — which is what the queue + holds. So #15 is this row, not a separate feature. +- **Skip costs the animation and never the information.** Everything skipped is already in the history + log. That is what makes the button safe to press without hesitation. +- Amber for the button. Self-hides at zero, so solitaire only sees it during an automatic-phase run. +- **Input is never blocked.** Any input skips to current. `pace = 0` turns the whole thing off, which + is also TODO #18's *"a player who has seen it a hundred times will want it off"* — no second + mechanism for it. + +### 7. #18 becomes a dwell setting + +Not a feature. Phases where nothing happened dwell at zero — Jesse, 2026-09-09: *"if nothing happens +during a phase then we shouldn't lose time to it"*, and on solitaire, *"you kind of look to see and +guess, 'Oh, I guess nothing happened in those phases'"*, which is acceptable. Phases where something +happened get a beat through the same queue. + +**A flat second per phase is explicitly rejected**: 5 phases × ~60 stages is about five minutes per +game of enforced dwell, most of it spent on phases where nothing happened. TODO's Reference · #18 +reached the same figure from the other direction (48s/Day). + +Solitaire inherits all of this through `LocalSession` rather than being special-cased. + +### 8. Carried along — switching logs unattributed + +`record()` attributes with `const mine = who !== null && 'player' in e` (`web/game.ts:1188`). +**`trayMoved`, `carsCoupled`, `carsDropped` and `consistSorted` carry no `player` field** — measured +2026-09-09, and they are the only events in their class that do not. `cardDrawn`, `cardPlayed`, +`cardDiscarded`, `carPlacedOnTrain`, `loadStarted`, `loadCompleted`, `flyingSwitch` and +`localOpsOptionChosen` all do. + +So switching — the one class of action Jesse most wants to follow — logs unattributed and with +`tone: 'plain'` instead of `'act'`, meaning it does not even read as somebody's move: + +``` +Player Alice chose to switch ← attributed +CREW moved (1,2) → (1,3) — 4 of 6 ← whose train? +CREW coupled 2 cars ← whose? +Player Alice finished Local Operations ← attributed +``` + +An attributed bracket around unattributed contents. Add `player` to those four events. Fixed here +rather than filed, because it is the same feature. + +### Tests + +- Collector: one human intent with no bot response; one human intent followed by several bot intents; + consecutive bot turns; bot pending decisions; automatic engine work inside one intent; ordering of + narration against frames; sequence continuity; player pushes still coalesced; **no steps emitted + during `resumeSession()`**; reconstructed state matching `publicSnapshot()` after the final step. +- Public delta: per-seat district deltas reconstruct a frame identical to a fresh full projection. +- Redaction: **done 2026-09-09**, by folding the steps into `everythingSeatSees` so that every + existing case covers them — the blind draw, the pending decision, Employee Rotation before and + after the seating moves, the reconnect and the played-out game — rather than adding one test beside + them. Doing it surfaced two false positives in the existing name-based heuristic, neither caused by + this feature, and the distinction they forced is worth keeping: + - **A card NAME is circumstantial evidence; a card ID is proof.** Ids are searched everywhere. Names + are not searched in two places that are legitimately entitled to carry them: lines naming a + **face-up pile** (§2.6 — a Department or the Salvage Yard 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 are a record of what was public *over time* rather + than a view of the position now. What guarantees a step frame is clean is the allow-list test on + `PublicFrame`, not a substring search over its history. + - The harness also passed `g.log` into `snapshot()` for the Frame's own `lines`, which **production + has not done since #97**. Now `[]`, matching `frameFor()`. The log is still audited in full, once. + - **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. +- Pacing: kind classification for every intent type in the `Intent` union, so a new intent cannot + land silently in the wrong tier; dwell arithmetic against the table; `pace = 0` produces no dwell. +- Queue: step order preserved; skip drains and applies final state; counter counts dwelling steps + only; a stopped queue stops its timers. +- Attribution: each of the four switching events narrates with the acting player's name and `act` + tone. +- Solitaire: `LocalSession` produces the same steps for the same intents as `GameSession` does. + ## Summary Add a privacy-safe common game board that can be viewed in a browser and published into the game’s Jitsi meeting by a server-managed headless Chromium participant. @@ -29,6 +327,8 @@ The implementation is divided into independently useful stages: 6. Supervise one headless Chromium process per published game. 7. Integrate lifecycle, configuration, packaging, health, and live verification. +Steps 1-4 are **v0.8.0** (1 shipped); steps 5-7 are **v0.9.0** — § THE RELEASE SPLIT. + The browser display remains useful without Jitsi. The public projection and leak fixes improve multiplayer security even if no visual display is deployed. ## Research incorporated @@ -61,15 +361,19 @@ Introduce dedicated allow-listed types. Do not derive them with `Omit **Read those two, not this**, when building steps 2-7. The differences that matter: > > - **The shape is FLAT, not grouped.** There is no `clock`, `config`, `scoring` or `deckCounts` -> object. Their contents sit at the top level — `day`, `stage`, `clock` (a time string), `phase`, -> `phaseKey`, `actor`, `superintendent`, `deck`, `departments`, `departmentDepth`, `salvage`, -> `yards`, `mode`, `days`, `optionalRules`, `houseRules`, `minCombinedRevenue`, -> `maxCollisionsPerDay`, `maxCollisionsTotal`, `collisionsToday`, `collisionsTotal`, `status`, -> `outcome`, `extraDays`, `extensionVotes`, `official`, `tally`, `openingRolls`, `timetable`, -> `timetableWhat`, `trains`, `players`, `division`, `districts`. -> - **`protocolVersion` was NOT built** and exists nowhere in the repo. Step 2 is the reconnecting -> display stream, which is the first thing that would want one — decide there whether to add it, -> rather than assuming it is already on the wire. +> object. Their contents sit at the top level. **All 37 properties, which is the same list as +> `test/redaction.test.ts`'s allow-list** — `day`, `stage`, `clock` (a time string), `phase`, +> `phaseKey`, `actor`, `superintendent`, `deck`, `departments`, `departmentsWhat`, +> `departmentDepth`, `salvage`, `yards`, `timetable`, `timetableWhat`, `houseRules`, `mode`, +> `optionalRules`, `days`, `minCombinedRevenue`, `maxCollisionsPerDay`, `maxCollisionsTotal`, +> `collisionsToday`, `collisionsTotal`, `status`, `outcome`, `extraDays`, `extensionVotes`, +> `official`, `tally`, `players`, `openingRolls`, `trains`, `crewTrays`, `queued`, `division`, +> `districts`. The first 35 come from `projectSharedTable`; `division` and `districts` are added +> by `PublicFrame` itself. +> - **`protocolVersion` was NOT built** and exists nowhere in the repo. **Decided 2026-09-09: add it +> in step 2.** `display.json` carries its own `schemaVersion`, and the SSE wire format is a second, +> independent contract — parsed in 0.9.0 by a headless agent page that lives for hours, where +> `build-web.ts`'s cache-busting does not help. > - **`redFlagHeld` is STRUCK OFF**, not deferred. See below. > - **Fields gained since this was written** that the renderer should know about: `crewTrays` and > `queued` (#98, the Crew Tray pool and the trains waiting for one), and on each district's cells @@ -275,6 +579,13 @@ Cover: Acceptance requires an allow-list review of every `PublicFrame` property. Passing redaction tests alone is insufficient. ## Step 2 — Display credentials, stream, and persistence +> **SPLIT (2026-09-09).** The **display-step stream** part of this step is superseded by § v0.8.0, +> which puts steps on the `Session` interface and on `Push.steps` — no endpoint, no credential, and +> no change to `http.ts`. What remains here is **v0.8.1**: `display.json`, the `viewToken`, the +> `/api/display/stream` SSE endpoint, `/display.html`, the security requirements, and the +> `startServer()` refactor plus the first HTTP test harness that testing those needs. `protocolVersion` +> and the per-seat public delta moved into 0.8.0. Findings below are from 2026-08-27 and unverified. + ### Display metadata @@ -305,22 +616,32 @@ For older saves without `display.json`, generate the metadata once on resume and ### HTTP endpoints -Add: +Add in **0.8.0**: - `GET /display.html#token=` — manual common-board page - `GET /api/display/stream?token=` — public-board SSE + +Deferred to **0.9.0** with the publisher (§ THE RELEASE SPLIT): + - `GET /display-agent.html` — internal headless publisher page -- WebSocket upgrade at `/api/display/control` — supervisor/agent control +- the supervisor/agent control channel, as **SSE down + POST up**, not a WebSocket upgrade Extend the authenticated game/session response with: - display URL -- Jitsi meeting URL -- sanitized publisher state +- sanitized publisher state (`'disabled'` until 0.9.0) + +The Jitsi meeting URL joins that response in 0.9.0. The browser display reads the fragment token, removes it from the visible address if practical, and supplies it to the SSE request. Fragments keep the credential out of the initial HTTP request and normal server access logs. -Do not add a publisher-configuration HTTP endpoint. The internal agent receives its meeting configuration and view token over the authenticated control WebSocket. +Do not add a publisher-configuration HTTP endpoint. The internal agent receives its meeting configuration and view token over its authenticated control channel. + +**0.8.0 must not presume an upgrade handler exists.** `startServer()` in `src/server/http.ts:169` +returns `void` today and there are no HTTP-level tests in the project at all — `test/server/` is +lobby, persistence and session only. Step 2 therefore builds the project's first HTTP test harness +on top of the `startServer()` refactor, which is a larger opening move than the one line it gets +below. TODO #102 is the record of why this half of the codebase was untested until v0.7.9.8. ### SSE behavior @@ -336,6 +657,14 @@ On connection: Use a dedicated public-frame delta function rather than the player `Frame` delta. The observed public snapshot grows enough during longer games that full frames for every action would be wasteful. +**This is new code, not a reuse, and the shape differs from the player delta** (checked 2026-09-09). +`src/sim/frame-delta.ts` hardcodes `BOARD_KEYS = ['cells', 'facilities', 'division']` against +`Frame`, but `PublicFrame` has no top-level `cells` or `facilities` — they live inside `districts[]`, +one entry per seat, which is where nearly all of the bytes are. **Delta `districts` per seat, not as +one array**: a single accepted intent changes one district, so a whole-array comparison sends every +other player's board again on every step. `deltaFrame`/`applyDelta`'s "null means unchanged, merged +against the last full frame the receiver holds" convention is worth keeping; the key set is not. + If an SSE client is slow or disconnected, close it and let EventSource reconnect to a new reset. Do not keep an unbounded replay buffer. ### Security requirements @@ -365,6 +694,15 @@ Cover: - display failure not interrupting `/api/intent` or player SSE ## Step 3 — Reusable common-board renderer +> **SPLIT (2026-09-09).** The **canvas** half of this step exists only to feed WebRTC — +> `canvas.captureStream(10)` is the only way to hand a rendered board to Jitsi, and DOM cannot be +> captured into a `MediaStream`. A TV or an OBS browser source renders HTML directly, and more +> crisply. So: the 1280×720 canvas, the 10fps draw loop, `contentHint: 'detail'`, SVG→image +> rasterization and the content-keyed image cache are all **v0.9.0**. The **layout** work — an +> all-districts seatless page, and adapting the Division/office SVG generators to render without a +> private viewer — is **v0.8.1**, and is cheap once 0.8.0's foreign-district rendering exists. The +> animation queue and the focused-district rule moved into 0.8.0 (§ v0.8.0 §§ 4, 6). + ### Renderer structure @@ -447,9 +785,30 @@ Test renderer lifecycle with a fake canvas/image layer: Perform visual review at 1280×720 and as a reduced Jitsi tile. Text and train positions must remain legible without opening a tooltip. ## Step 4 — Preserve individual human and bot actions +> **SUPERSEDED BY § v0.8.0 (2026-09-09).** Read that section, not this one. The collector design here +> is broadly right and its "Current code findings" were re-verified 2026-09-09, so both are kept — +> but three things changed: the collector lives in `sim/` and is called by `LocalSession` too rather +> than being a `GameSession` private; *"Keep player pushes unchanged"* below is **reversed** (that was +> the open question, and #13 is the answer); and the mechanism now includes foreign-district +> rendering, pacing by kind, and the behind-counter, none of which are described here. + ### Current code findings +> **RE-VERIFIED 2026-09-09.** The three findings below still hold, and the insertion point is as +> cheap as they imply: `intent()` (`src/server/session.ts:361`) is synchronous — `check` → `submit` +> → `driveBotTurns()` → `pushesForAll()` — and `driveBots()` (`:320`) loops `submit()`. A collector +> after each `submit()` is two call sites and needs no async surgery. **Two traps that are not in +> the text below and were found by reading the code rather than the plan:** +> +> - **The collector must be inert during replay.** `resumeSession()` rebuilds a game by replaying +> its whole history through `submit()`. Wired naively, every server restart re-emits the entire +> game as display steps and burns `nextSequence` in `display.json`. This is the same class of bug +> as #97 — a mechanism firing on a path nobody pictured it running on. +> - **Narration-per-step needs its own high-water mark.** `sentLines` is per-seat and is *mutated* +> by `linesSince()` as a side effect of building a push, so step 2 of "Required changes" cannot +> read it. Track an independent mark against `game.log.length`. + `GameSession.intent()` applies the human intent, runs `driveBots()`, and only then creates player pushes. `driveBots()` can call `submit()` many times. Player deltas intentionally collapse those moves into one final state, which is appropriate for gameplay but would make bots appear to teleport through several actions on the common board. @@ -480,6 +839,24 @@ One display step corresponds to one accepted intent, including automatic consequ Keep player pushes unchanged: players still receive the final coalesced result after all immediately due bots finish. +> **REVERSED FOR 0.8.0 — RESOLVED 2026-09-09. See § v0.8.0.** +> +> The sentence above scoped step 4 to the seatless board. It was the open question of this design and +> it now has an answer: **seated players receive steps too**, because #13 is what 0.8.0 is for. +> +> How the three objections that made it an open question were settled: +> +> - *A player waiting to act cannot lag a second behind.* Answered by the **behind-counter and skip** +> (§ v0.8.0 § 6) rather than by policy — the lag is shown, counted down, and skippable, so the +> player decides instead of the design guessing. +> - *A step sent to a seat is a `Frame`, and `Frame`s are redacted per seat.* Answered by **not +> sending a `Frame`**: public steps animate the board and the existing private Push supplies hand, +> menu and objective. Zero new redaction surface. +> - *Animating other people's turns makes the game feel slower.* Answered by **dwell by kind** — the +> measured cost is ~40s of animation across a 60-stage game, and bookkeeping dwells at zero. +> +> Player pushes are still coalesced; what changes is that they now also carry `steps`. + Opening bot moves that occur before any client connects do not need replay. Persist the resulting game state and sequence; a later display receives the final reset. ### Failure isolation @@ -509,6 +886,14 @@ Cover: ## Step 5 — Minimal visual-only Jitsi engine +> **v0.9.0 — DEFERRED (2026-09-09).** Steps 5-7 are the Jitsi publisher and are explicitly held +> off until 0.8.0 ships and Chromium has been measured on `phoenix.local`. Nothing below has been +> re-verified against the current code; it was written 2026-08-27. Two things will have moved by +> the time it is picked up: the control channel is **SSE + POST, not a WebSocket** (§ THE RELEASE +> SPLIT), and adding Chromium takes the `.s9pk` from 63 MB to several hundred against a package +> that declares no `hardwareRequirements` today. + + ### Source strategy Port the smallest relevant production patterns from `jitsi-transcription` into Station Master with attribution where required. Do not import the sibling repository at runtime, add it as a submodule, or copy its transcription/audio/chat features. @@ -530,7 +915,12 @@ Pin the known working `lib-jitsi-meet` release: v2192.0.0+d6f3312f ``` -Add `ws` for the Node control broker. Do not add Playwright. +**Do not add `ws`, and do not add Playwright.** The control channel is SSE down + POST up +(§ THE RELEASE SPLIT), so it needs nothing beyond `node:http`, which the server already uses. +`lib-jitsi-meet` is browser-side only: vendor the pinned release into `public/` and load it with a +`