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 6b6a03613f
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
+12 -1
View File
@@ -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
+60 -18
View File
@@ -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
+430 -21
View File
@@ -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<StepKind, number> = {
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 `<main>` 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<Frame, ...
> **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=<viewToken>` — manual common-board page
- `GET /api/display/stream?token=<viewToken>` — 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
`<script>` tag — there is no bundler in this project, `scripts/build-web.ts` runs bare `tsc`. Pin
and checksum it; it is a multi-megabyte minified blob entering the repo, which is a decision to
take deliberately rather than a build artefact.
### Agent page
@@ -547,7 +937,7 @@ Add `ws` for the Node control broker. Do not add Playwright.
9. Report normalized lifecycle state over the control channel.
10. Leave and stop all tracks on supervisor command or `pagehide`.
Meeting server, room, XMPP configuration, and view token are delivered over the control WebSocket, not placed in query parameters.
Meeting server, room, XMPP configuration, and view token are delivered over the control channel, not placed in query parameters.
### Jitsi publishing
@@ -636,6 +1026,14 @@ Port/adapt the sibling repository’s proven tests for:
## Step 6 — Chromium publisher supervisor
> **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.
### Process model
Create one Chromium child process per published game.
@@ -699,7 +1097,8 @@ The broker must:
- allow a newer engine to supersede only the same session
- reject client/engine role changes on an established socket
- clear pending commands when an engine disconnects
- reconnect the agent-side WebSocket every two seconds until stopped
- reconnect the agent-side control stream every two seconds until stopped — note that an
`EventSource` does this by itself, which is part of why the transport changed
Public frames remain on SSE and never traverse this control protocol.
@@ -760,6 +1159,14 @@ Use fake child processes and fake control sockets to test:
## Step 7 — Configuration, lifecycle, packaging, and observability
> **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.
### Configuration
Support:
@@ -844,8 +1251,10 @@ Exclude all tokens, full control frames, player private state, query strings, an
Add runtime dependencies:
- the pinned `lib-jitsi-meet` release
- `ws`
- the pinned `lib-jitsi-meet` release, vendored as a browser asset rather than an npm runtime import
**No Node runtime dependency is added.** `package.json` has no `dependencies` key and the wrapper's
runtime image copies only `package.json` and `src/` — see § THE RELEASE SPLIT. Keep it that way.
Add Chromium and `tini` to the Station Master runtime image or companion StartOS packaging repository.
@@ -912,7 +1321,7 @@ Capture:
- raw display SSE
- rendered canvas screenshots
- control WebSocket messages
- control-channel messages
- Jitsi network destinations
- server logs
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "station-master",
"version": "0.7.9.8",
"version": "0.8.0",
"private": true,
"type": "module",
"description": "Station Master — a railroad operations game",
+4 -1
View File
@@ -1642,6 +1642,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
const events: GameEvent[] = [
{
type: 'trayMoved',
player,
trayId: i.trayId,
from,
to: i.to,
@@ -1706,6 +1707,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
// decides which car is next to come off.
events.push({
type: 'carsCoupled',
player,
trayId: i.trayId,
at: i.to,
stock: dest.couples,
@@ -1726,7 +1728,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
const stock = i.fromNose
? tray.consist.slice(0, i.count)
: tray.consist.slice(tray.consist.length - i.count);
return [{ type: 'carsDropped', trayId: i.trayId, at: here, stock, ...(i.fromNose ? { fromNose: true } : {}) }];
return [{ type: 'carsDropped', player, trayId: i.trayId, at: here, stock, ...(i.fromNose ? { fromNose: true } : {}) }];
}
case 'switch.sortConsist': {
@@ -1735,6 +1737,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
return [
{
type: 'consistSorted',
player,
trayId: i.trayId,
at: here,
before: tray.consist.map((c) => ({ ...c })),
+4 -3
View File
@@ -37,9 +37,10 @@ export type GameEvent =
* more than one legal route to `to`, so the history can say which one ran rather than leaving a
* choice the player made invisible in their own log.
*/
| { type: 'trayMoved'; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number; facing?: 'n' | 's' | 'e' | 'w'; via?: GridCoord }
| { type: 'trayMoved'; player: PlayerIndex; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number; facing?: 'n' | 's' | 'e' | 'w'; via?: GridCoord }
| {
type: 'carsCoupled';
player: PlayerIndex;
trayId: TrayId;
at: GridCoord;
stock: RollingStock[];
@@ -71,8 +72,8 @@ export type GameEvent =
*/
recoupled?: { at: GridCoord; stock: RollingStock[] };
}
| { type: 'carsDropped'; trayId: TrayId; at: GridCoord; stock: RollingStock[]; fromNose?: boolean }
| { type: 'consistSorted'; trayId: TrayId; at: GridCoord; before: RollingStock[]; after: RollingStock[] }
| { type: 'carsDropped'; player: PlayerIndex; trayId: TrayId; at: GridCoord; stock: RollingStock[]; fromNose?: boolean }
| { type: 'consistSorted'; player: PlayerIndex; trayId: TrayId; at: GridCoord; before: RollingStock[]; after: RollingStock[] }
| { type: 'cardDrawn'; player: PlayerIndex; source: 'homeOffice' | 'department'; slot?: number; cardId: CardId }
/**
* §6.2 — the Home Office deck ran out, so the Salvage Yard and all three Department decks were
+55 -5
View File
@@ -26,8 +26,10 @@ import { actionMenu, currentActor, fromMultiplayerSave, isOutOfTurn, newMultipla
import type { Game, Menu } from '../web/game.ts';
import { deltaFrame } from '../sim/frame-delta.ts';
import type { FrameDelta } from '../sim/frame-delta.ts';
import { snapshot, seatLabel } from '../sim/view.ts';
import type { Frame } from '../sim/view.ts';
import { publicSnapshot, snapshot, seatLabel } from '../sim/view.ts';
import type { Frame, PublicFrame } from '../sim/view.ts';
import { takeSteps } from '../sim/display-step.ts';
import type { DisplayStep } from '../sim/display-step.ts';
import { developerBot } from '../sim/bot.ts';
export type Push = {
@@ -69,6 +71,29 @@ export type Push = {
scheduled?: number | null;
announcement?: string | null;
justDrawn?: string | null;
/**
* ORDERED PRESENTATION STEPS — v0.8.0, TODO #13.
*
* A field on `Push` rather than a second SSE event type, following `presence`'s precedent and for
* its stated reason (`http.ts`): one message shape for the client to parse. `http.ts` therefore
* needs no change at all — `broadcastGame` forwards whatever this file builds.
*
* IDENTICAL IN EVERY SEAT'S PUSH, because a step carries the PUBLIC board and nothing else. A
* player's own hand, menu and objective are not animated: they arrive on the same push, already
* coalesced, exactly as they always have. That is what keeps the redaction surface at zero new
* area — `test/redaction.test.ts` guards the projection these are built from.
*/
steps?: DisplayStep[];
/**
* The public board to start a step queue from — sent on a CONNECT, never on an update.
*
* Steps carry deltas against one chain shared by the whole table, so a client that has just
* arrived (or come back) has nothing to merge the next delta onto and `applyPublicDelta` would
* rightly throw. This is that baseline: the exact frame the chain has reached, so the next step
* lands on it. A reconnecting client resets rather than replaying what it missed — the history
* panel is what carries the words, and it is already sent whole on connect (#97).
*/
publicReset?: PublicFrame;
};
/**
@@ -211,11 +236,12 @@ function buildSession(
return { cues, scheduled, announcement };
}
function pushFor(seat: PlayerIndex, moment: Moment | null): Push {
function pushFor(seat: PlayerIndex, moment: Moment | null, steps: DisplayStep[] = []): Push {
const frame = frameFor(seat);
const delta = deltaFrame(lastFrame.get(seat) ?? null, frame);
lastFrame.set(seat, frame);
const push: Push = { frame: delta, menu: menuFor(seat), lines: linesSince(seat) };
if (steps.length > 0) push.steps = steps;
if (moment) {
if (moment.cues.length > 0) push.cues = moment.cues;
if (moment.scheduled !== null) push.scheduled = moment.scheduled;
@@ -228,9 +254,13 @@ function buildSession(
function pushesForAll(): Map<PlayerIndex, Push> {
const moment = takeMoment();
// Drained ONCE for the whole broadcast, not per seat: the steps are public and identical, and
// `takeSteps` empties the collector, so draining inside the loop would give them to seat 0 and
// an empty list to everybody else.
const steps = takeSteps(game.display);
const out = new Map<PlayerIndex, Push>();
for (let seat = 0; seat < playerNames.length; seat++) {
out.set(seat as PlayerIndex, pushFor(seat as PlayerIndex, moment));
out.set(seat as PlayerIndex, pushFor(seat as PlayerIndex, moment, steps));
}
return out;
}
@@ -341,6 +371,19 @@ function buildSession(
// here rather than fired at the first client to arrive. (It also stops `game.cues` growing without
// bound on a server, which nothing was draining before this.)
takeMoment();
/**
* THE PRESENTATION STEPS THOSE TURNS PRODUCED GO WITH THEM (v0.8.0).
*
* Left in the collector they would be delivered on the FIRST broadcast after somebody connects —
* but that client's `publicReset` is the board as it stands AFTER these very moves, so replaying
* them onto it would draw positions the game had already left. The plan says as much: opening bot
* moves need no replay, and a later display simply receives the final reset.
*
* This is the only moment the collector holds anything outside an intent. `pushesForAll()` drains
* it synchronously at the end of every `intent()`, so between moves it is always empty — which is
* what makes dropping here safe rather than a race with a seat that has not been sent them yet.
*/
takeSteps(game.display);
return {
playerCount: playerNames.length,
@@ -355,7 +398,14 @@ function buildSession(
// blank history panel mid-game, with the server holding the whole log. `Push.lines` on a
// connect IS the history, which is what lets the Frame stop carrying a second copy.
sentLines.delete(seat);
return pushFor(seat, null);
const push = pushFor(seat, null);
/**
* The baseline for this client's step queue (v0.8.0). `game.display.last` is the exact frame
* the shared delta chain has reached, so the next step merges onto it; before any step has
* been collected there is no chain yet and a fresh projection is the same thing.
*/
push.publicReset = game.display.last ?? publicSnapshot(game.state);
return push;
},
intent(seat, seq, i) {
+134
View File
@@ -0,0 +1,134 @@
/**
* THE DISPLAY-STEP COLLECTOR — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 §§ 1-3.
*
* One ordered, watchable presentation step per accepted intent, so a player can see what everyone
* else did instead of finding the board already rearranged. TODO #13: *"It's not fun to do my turn
* and have magic happen in the background and then have to figure out what others did."*
*
* ONE HOOK, NOT TWO. The design anticipated wiring this into `GameSession.intent()` and
* `GameSession.driveBots()` separately, with `LocalSession` doing its own thing for solitaire. 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 there is what
* makes solitaire a special case of multiplayer rather than a second implementation, which is the
* standing design direction for this codebase.
*
* AND REPLAY IS INERT FOR FREE. `fromSave` and `fromMultiplayerSave` rebuild a game by calling
* `applyIntent` + `record` + `drain` directly rather than `submit`, so a resumed server or a rebuilt
* undo does NOT re-emit the whole game as steps. That was expected to need an explicit guard — the
* plan calls it out as the same class of bug as #97, a mechanism firing on a path nobody pictured
* it running on. It needs none, but the property is load-bearing: **if a replay path is ever moved
* onto `submit()`, this becomes a real bug**, and `test/watchable.test.ts` pins it.
*
* WHAT A STEP IS. One accepted intent, or ONE AUTOMATIC PHASE — never one per `GameEvent`, because
* the event list is not a complete reducer and a receiver could not rebuild state from it. It gets a
* projected frame instead.
*
* PHASES EARN THEIR OWN STEPS, and that is TODO #18. `pump()` runs every automatic phase between one
* click and the next and `drain()` records the whole batch at once, so New Train, the Mainline and
* the shift change "look like they are being skipped entirely" — trains cross the Division in one
* jump. Folding them into the triggering intent's step reproduces exactly that. So `submit()` steps
* `advance()` one call at a time instead, and collects a step for each phase that actually DID
* something. A phase that did nothing adds no narration and therefore produces no step at all, which
* is Jesse's own rule (2026-09-09): "if nothing happens during a phase then we shouldn't lose time
* to it."
*
* `drain()` is deliberately NOT changed. Replay, undo and `fromSave` all use it, and the
* replay-inertness property below depends on their staying off this path. The stepped version lives
* in `submit()` and makes the same `advance()` calls in the same order, so the resulting state is
* identical — only the collection differs.
*/
import type { Intent } from '../engine/intents.ts';
import type { GameState, PlayerIndex, SeatIndex } from '../engine/state.ts';
import { seatOf } from '../engine/state.ts';
import { publicSnapshot } from './view.ts';
import type { PublicFrame } from './view.ts';
import { deltaPublicFrame } from './public-delta.ts';
import type { PublicFrameDelta } from './public-delta.ts';
/**
* The wire format's version, on the ENVELOPE rather than on the projection.
*
* The plan's original sketch put `protocolVersion` inside `PublicFrame`. It does not belong there:
* `PublicFrame` is a projection of the game and its property list is an allow-list that
* `test/redaction.test.ts` enumerates, so a transport concern living in it would have to be
* allow-listed as public game state, which it is not. The step is the message; the message carries
* the version.
*/
export const DISPLAY_PROTOCOL_VERSION = 1;
/** What produced a step: somebody's intent, or the Division advancing a phase by itself. */
export type StepCause = Intent['type'] | 'phase';
/** One watchable thing that happened, in order. */
export type DisplayStep = {
protocolVersion: typeof DISPLAY_PROTOCOL_VERSION;
/** Monotonic per game. 0.8.1's reconnecting display stream needs it to detect a gap; a queue only needs the order. */
seq: number;
/**
* Who acted — NULL for an automatic phase, which nobody did.
*
* Both are carried because Employee Rotation makes "which seat" and "which player" different
* questions.
*/
player: PlayerIndex | null;
seat: SeatIndex | null;
/** What caused it — the input to pacing's kind classification. */
cause: StepCause;
/** The public board after this intent and everything it drained, against the previous step. */
frame: PublicFrameDelta;
/** The narration this intent added, in order, including any phase lines drained behind it. */
lines: { text: string; tone: string }[];
};
/**
* Per-game collector state.
*
* Held on `Game` beside `log`, `cues` and `announced` and drained the same way, which is the
* established convention in this codebase for "the model accumulated something, the view takes it".
*/
export type DisplayCollector = {
/** Undrained steps, oldest first. */
steps: DisplayStep[];
/** The last public frame a step was built against, so the next delta has something to diff. */
last: PublicFrame | null;
/** Next sequence number to assign. */
seq: number;
};
export function newCollector(): DisplayCollector {
return { steps: [], last: null, seq: 0 };
}
/**
* Record one accepted intent as a step.
*
* Called from `submit()` AFTER `record()` and `drain()`, so `state` is the position the intent
* finally produced and `lines` is everything it caused to be said. The frame is projected
* immediately and never from a retained `GameState` reference — a retained reference would resolve
* to the FINAL state of a whole bot run, which is exactly the teleporting this exists to prevent.
*/
export function collectStep(
collector: DisplayCollector,
state: GameState,
player: PlayerIndex | null,
cause: StepCause,
lines: { text: string; tone: string }[],
): void {
const next = publicSnapshot(state);
collector.steps.push({
protocolVersion: DISPLAY_PROTOCOL_VERSION,
seq: collector.seq++,
player,
seat: player === null ? null : seatOf(state, player),
cause,
frame: deltaPublicFrame(collector.last, next),
lines,
});
collector.last = next;
}
/** Take everything collected so far, leaving the collector empty — `takeMoment()`'s pattern. */
export function takeSteps(collector: DisplayCollector): DisplayStep[] {
return collector.steps.splice(0, collector.steps.length);
}
+2 -2
View File
@@ -155,7 +155,7 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
return {
tone: 'plain',
where: e.to,
text: `CREW moved ${at(e.from)} → ${at(e.to)}${e.via ? ` via ${at(e.via)}` : ''} — ${e.movesRemaining} of 6 Moves left. The crew chip on the grid carries the whole train with it.`,
text: `Moved ${train(e.trayId)} ${at(e.from)} → ${at(e.to)}${e.via ? ` via ${at(e.via)}` : ''} — ${e.movesRemaining} of 6 Moves left. The crew chip on the grid carries the whole train with it.`,
};
case 'carsCoupled': {
/**
@@ -181,7 +181,7 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
return {
tone: 'good',
where: e.at,
text: `SMALL YARD — consist re-ordered from [${carsLabel(e.before)}] to [${carsLabel(e.after)}], so the right car is now on the end and can be spotted`,
text: `Used the SMALL YARD — consist re-ordered from [${carsLabel(e.before)}] to [${carsLabel(e.after)}], so the right car is now on the end and can be spotted`,
};
case 'carsDropped':
// WHICH END. A cut comes off an outer end (§A.3) and the end decides everything that follows:
+167
View File
@@ -0,0 +1,167 @@
/**
* HOW LONG EACH STEP IS SHOWN — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 § 5.
*
* Shared rather than living in `src/web/`, so the 0.8.1 seatless board paces identically to a
* player's own screen. Two views of one game that disagreed about how fast it looks would be worse
* than either alone.
*
* WHY BY KIND RATHER THAN BY BUDGET. The obvious scheme is to give the whole backlog a time budget
* and divide it by the queue length. Measured against real games, that does exactly the wrong
* thing. From `public/replays/`: ~60 stages per game and ~5 intents per player per stage, so a
* four-player table produces ~15 other-player steps per stage — but 44 of a 307-intent game are
* `draw.end` and 60 are `loadUnload.end`, bookkeeping nobody wants to watch, while the thing that
* is worth watching is rare and clustered. Two of the three published replays contain no
* `switch.move` at all; the third has bursts of 14, 6, 6 and 6, and `trayMoved`'s own narration says
* "N of 6 Moves left" because six is the engine's cap per crew. So a uniform budget spends the
* player's attention on `draw.end` and rushes the switching.
*
* Assigning dwell by kind and letting the total fall out costs ~40s of animation across a whole
* 60-stage game, against ~3.6 minutes for a flat 700ms — better switching visibility for a fifth of
* the time. Jesse, 2026-09-09, on what matters: *"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."*
*
* PACING IS CLIENT-SIDE ONLY. The server emits steps as fast as it likes and the client decides how
* to show them, which is what keeps Gitea#20's "do not slow the authoritative game" true.
*/
import type { StepCause } from './display-step.ts';
export type StepKind = 'switching' | 'action' | 'phase' | 'bookkeeping';
/**
* THE TUNING TABLE — dwell in milliseconds per kind.
*
* Start generous and tune down by playing; Jesse, 2026-09-09: *"start at 1s and tune down."* This is
* the committed default and changing it needs a web rebuild, which in the `.s9pk` is a release — so
* it is deliberately not the only way to change the pacing. A viewer's own `pace` multiplier
* (`Settings`, `localStorage`) and a `?pace=` URL parameter both scale these without one, and
* `pace = 0` turns the animation off entirely, which is also TODO #18's "a player who has seen it a
* hundred times will want it off".
*
* NOT IN GAME-CREATION SETTINGS, on Jesse's call 2026-09-09: dwell is presentation, not a rule, and
* `config` rides along in saves and replays. If it ever moves there, the config field supplies this
* table's multiplier — the table, the classification and the queue do not change.
*/
export const DWELL: Record<StepKind, number> = {
/** A train physically moving on the board. The thing worth watching, and protected accordingly. */
switching: 1000,
/** A card, a car or a load changing hands somewhere visible. */
action: 250,
/**
* An automatic phase that DID something — TODO #18.
*
* Only reached when the phase actually narrated: `submit()` collects no step for a phase that
* changed nothing, so this is never spent on the empty ones Jesse is content to guess at. Between
* an ordinary action and a switching move, because the Mainline phase moves trains the length of
* the Division and is the clearest case of "stuff just happened without being able to see how".
*/
phase: 600,
/** Turn and phase bookkeeping. Nothing moved; do not spend the player's attention on it. */
bookkeeping: 0,
};
/**
* Which kind an intent is.
*
* Exhaustive over `Intent['type']` on purpose — a `default` would silently drop a newly added intent
* into whatever tier the fallback names, and the failure mode is invisible (a move that never gets
* a beat, or bookkeeping that stalls the queue for a second). `test/pacing.test.ts` walks every
* member of the union so a new intent cannot land here unclassified.
*/
export function kindOf(cause: StepCause): StepKind {
switch (cause) {
// The Division advancing itself — New Train, the Mainline, the shift change (TODO #18).
case 'phase':
return 'phase';
// The crew and its train moving, coupling, setting out and re-ordering — §6.1 and Appendix A.
case 'switch.move':
case 'switch.dropCars':
case 'switch.sortConsist':
case 'maneuver.flyingSwitch':
case 'maneuver.redFlags':
return 'switching';
// Something visible changed hands or position, but no train drove anywhere.
case 'card.play':
case 'card.discard':
case 'draw.fromHomeOffice':
case 'draw.fromDepartment':
case 'newTrain.placeCar':
case 'newTrain.passCar':
case 'newTrain.secondSection':
case 'newTrain.startExtra':
case 'porter.board':
case 'porter.detrain':
case 'laborer.startLoad':
case 'laborer.advanceLoad':
case 'laborer.beginUnload':
case 'freightAgent.stockOutbound':
case 'freightAgent.clearInbound':
case 'freightAgent.unjam':
case 'mainline.clearance':
case 'mainline.modify':
case 'mainline.redFlag':
case 'mainline.yardOffice':
case 'redFlag.play':
return 'action';
// Ending a phase or a turn, choosing what to do, voting. The consequences are worth watching;
// the declaration itself is not, and there are more of these than of anything else.
case 'localOps.choose':
case 'loadUnload.end':
case 'draw.end':
case 'switch.end':
case 'freightAgent.end':
case 'game.extend':
return 'bookkeeping';
}
}
/** How long to show one step, in ms, at a given speed. `pace` of 0 means "do not animate at all". */
export function dwellFor(cause: StepCause, pace = 1): number {
return Math.round(DWELL[kindOf(cause)] * Math.max(0, pace));
}
/**
* How long to show one STEP — the form the queue actually uses.
*
* A step that said nothing gets no dwell, whatever caused it. That is one rule covering two cases
* arrived at separately: a phase where nothing happened (Jesse, 2026-09-09 — *"if nothing happens
* during a phase then we shouldn't lose time to it"*), and a phase that only handed the turn on,
* which changes the board but has nothing on it to look at. Structurally typed so this file does not
* have to import `DisplayStep` back from the module that imports `StepCause` from it.
*/
export function dwellForStep(
step: { cause: StepCause; lines: readonly unknown[]; frame: { table: object } },
pace = 1,
): number {
if (step.lines.length > 0) return dwellFor(step.cause, pace);
/**
* A SILENT STEP EARNS A BEAT ONLY WHEN THE CLOCK TURNED OVER — which is TODO #18 exactly: "give
* every phase a visible beat", for New Train, the Mainline and the shift change.
*
* Measured, because the obvious rule was wrong twice. "No narration, no dwell" looked right and
* silently killed #18: a phase can move trains without saying anything, and those steps were being
* flashed past. "Anything that changed the board" is wrong the other way: `submit()` steps
* `advance()` about 4.6 times per intent and most of those merely hand the turn on, so beating on
* all of them would cost a quarter of an hour a game. The phase turning over is the thing a player
* is being shown, and there are about 180 of those in a full game.
*/
const table = step.frame.table as Record<string, unknown>;
const turned = 'phase' in table || 'phaseKey' in table || 'stage' in table || 'day' in table;
return turned ? dwellFor(step.cause, pace) : 0;
}
/**
* How many steps in a queue are actually going to be WATCHED.
*
* This is the number the "N behind" counter shows, and it is deliberately not `queue.length`. With
* bookkeeping dwelling at zero, a backlog of 17 where 12 are `*.end` would read "17", plummet to 5
* the instant it started, and then crawl — which is not the steady countdown the counter is for.
* Thirteen dwelling steps means thirteen things you are going to see.
*/
export function watchableCount(causes: readonly StepCause[], pace = 1): number {
return causes.filter((c) => dwellFor(c, pace) > 0).length;
}
+132
View File
@@ -0,0 +1,132 @@
/**
* Delta for the SEATLESS public frame — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 § 3.
*
* `frame-delta.ts` solves the same-shaped problem for a seated player's `Frame` and does NOT carry
* over, which is worth saying plainly because reusing it looks obvious and is wrong. It nulls three
* TOP-LEVEL keys — `cells`, `facilities`, `division` — and a `PublicFrame` has only the last of
* those. Its `cells` and `facilities` live one level down, inside `districts[]`, one entry per seat,
* and that is where nearly all of the bytes are.
*
* **So the districts are deltaed PER SEAT rather than as one array.** One accepted intent changes
* one district; comparing the whole array as a unit would resend every other player's board on
* every step, which is exactly the cost this exists to avoid. On a four-player table that is three
* boards of waste per step, and a step is emitted for every bot move as well as every human one.
*
* It is also a TRUE PARTIAL rather than a full frame with holes in it, which is the other place
* `frame-delta.ts` does not carry over. See `PublicFrameDelta` below for the measurement that forced
* that; in short, most steps change one field and shipping the other thirty-four cost 16.7 MB a game.
*
* The convention that does carry over, kept identical so a reader of one file can read the other:
* an absent or `null` field means "unchanged since the last thing sent to this receiver", and the
* receiving side merges against the last full frame it actually holds. A first connect or a
* reconnect after a gap sends a full frame instead — the display stream resets rather than replaying
* (§ v0.8.0).
*
* Node-free by design, like `frame-delta.ts`: the server and the browser both import this directly.
*/
import type { CellView, DivisionView, FacilityView, PublicDistrict, PublicFrame } from './view.ts';
/**
* One district with its two heavy fields nulled when unchanged.
*
* `seat` is the identity and is always present — it is what the receiver matches on. `player` and
* `name` are always sent too, and deliberately: Employee Rotation moves players between districts,
* so the pairing of seat to player is itself news, and it costs two small fields to never have to
* reason about whether a relabelling was missed.
*/
export type PublicDistrictDelta = Omit<PublicDistrict, 'cells' | 'facilities'> & {
cells: CellView[] | null;
facilities: FacilityView[] | null;
};
/** The shared-table half of a `PublicFrame` — everything that is not the Division or a district. */
type PublicTable = Omit<PublicFrame, 'division' | 'districts'>;
/**
* A `PublicFrame` reduced to WHAT CHANGED.
*
* **Partial, not a full frame with holes**, and that distinction was measured rather than assumed.
* The first version of this spread `...next` and nulled only the board fields, so every step shipped
* all 35 top-level properties even when the sole change was whose turn it was. Once TODO #18 gave
* automatic phases their own steps, most steps became exactly that — a turn handed on, nothing to
* look at — and a full 6-day game cost **19.4 MB**, of which **16.7 MB was those silent steps at
* ~11 KB each**. As a partial they are a few dozen bytes.
*/
export type PublicFrameDelta = {
/** Only the shared-table fields whose value differs from the previous frame. */
table: Partial<PublicTable>;
/** The Division, only when it changed. */
division: DivisionView[] | null;
/** Only the districts that changed, each carrying only the board fields that changed. */
districts: PublicDistrictDelta[];
};
const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
const TABLE_KEYS = (frame: PublicFrame): (keyof PublicTable)[] =>
(Object.keys(frame) as (keyof PublicFrame)[]).filter(
(k): k is keyof PublicTable => k !== 'division' && k !== 'districts',
);
/**
* `previous` is the last public frame actually sent to THIS receiver, or `null` for a first connect
* or a reset — in which case everything is sent in full.
*/
export function deltaPublicFrame(previous: PublicFrame | null, next: PublicFrame): PublicFrameDelta {
const before = new Map(previous?.districts.map((d) => [d.seat, d]) ?? []);
const table: Partial<PublicTable> = {};
for (const key of TABLE_KEYS(next)) {
if (previous === null || !same(previous[key], next[key])) {
(table as Record<string, unknown>)[key] = next[key];
}
}
const districts: PublicDistrictDelta[] = [];
for (const d of next.districts) {
const was = before.get(d.seat);
const cells = was && same(was.cells, d.cells) ? null : d.cells;
const facilities = was && same(was.facilities, d.facilities) ? null : d.facilities;
// A district with nothing new is left out entirely rather than sent as a row of nulls: on a
// four-player table three of them are unchanged on every single step.
if (was && cells === null && facilities === null && same(was, d)) continue;
districts.push({ ...d, cells, facilities });
}
return {
table,
division: previous !== null && same(previous.division, next.division) ? null : next.division,
districts,
};
}
/** The receiving side: merges a delta back onto the last full public frame this receiver holds. */
export function applyPublicDelta(previous: PublicFrame | null, delta: PublicFrameDelta): PublicFrame {
const base = previous ?? (delta.table as PublicTable);
const merged = { ...base, ...delta.table } as PublicTable;
const bySeat = new Map((previous?.districts ?? []).map((d) => [d.seat, d]));
for (const d of delta.districts) {
const was = bySeat.get(d.seat);
bySeat.set(d.seat, {
...d,
cells: d.cells ?? need(was?.cells, `districts[seat ${d.seat}].cells`),
facilities: d.facilities ?? need(was?.facilities, `districts[seat ${d.seat}].facilities`),
});
}
return {
...merged,
division: delta.division ?? need(previous?.division, 'division'),
districts: [...bySeat.values()].sort((a, b) => a.seat - b.seat),
};
}
/**
* A delta that says "unchanged" against a receiver that has nothing to merge onto is a bug in the
* SENDER's bookkeeping, not a recoverable state — it means the two sides disagree about what has
* been delivered, and quietly producing a frame with a missing board would put a blank district in
* front of a player. `frame-delta.ts` throws in the same situation and for the same reason.
*/
function need<T>(value: T | undefined, what: string): T {
if (value === undefined) {
throw new Error(`deltaPublicFrame said "${what}" is unchanged, but there is no previous frame to merge onto`);
}
return value;
}
+74 -4
View File
@@ -23,7 +23,7 @@
* folding events does not rebuild a game — `protocol.md` §3.)
*/
import { pump } from '../engine/advance.ts';
import { advance, pump } from '../engine/advance.ts';
import { applyIntent } from '../engine/apply.ts';
import type { GameEvent } from '../engine/events.ts';
import type { Intent } from '../engine/intents.ts';
@@ -33,6 +33,8 @@ import type { CardId, GameConfig, GameState, PlayerIndex } from '../engine/state
import { overHandLimit as overHandLimitOf } from '../engine/state.ts';
import { playerAtSeat } from '../engine/state.ts';
import { cuesFor, narrate } from '../sim/narrate.ts';
import { collectStep, newCollector } from '../sim/display-step.ts';
import type { DisplayCollector } from '../sim/display-step.ts';
// Import from the view module, NOT replay.ts — replay.ts writes files and reads process.argv,
// which would pull node:fs into a browser bundle.
import {
@@ -276,6 +278,16 @@ export type Game = {
* having taken a turn to cause it.
*/
announced: string | null;
/**
* ORDERED PRESENTATION STEPS — v0.8.0, TODO #13. One per accepted intent, so a player can WATCH
* what everyone else did rather than find the board already rearranged.
*
* Accumulated here beside `log`, `cues` and `announced` and drained the same way, because that is
* how this file already hands things to whatever is displaying the game. Filled by `submit()`
* alone, which is what makes it identical for solitaire and multiplayer and inert during replay —
* see `sim/display-step.ts`.
*/
display: DisplayCollector;
};
/** How each intent kind is introduced in the action list, in the order they should appear. */
@@ -311,7 +323,7 @@ export const SOLO_PLAYER = 'Solitaire';
export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
const state = createGame({ id: `web-${seed}`, seed, config, playerNames: [SOLO_PLAYER] });
const game: Game = { state, seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null };
const game: Game = { state, seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null, display: newCollector() };
// A history that opens mid-Stage reads as though something was missed. Say what the game IS
// first, then let the clock take over.
game.log.push({ text: 'Game Begins', tone: 'start' });
@@ -330,7 +342,7 @@ export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
*/
export function newMultiplayerGame(seed: number, config: GameConfig, playerNames: string[]): Game {
const state = createGame({ id: `mp-${seed}`, seed, config, playerNames });
const game: Game = { state, seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null };
const game: Game = { state, seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null, display: newCollector() };
game.log.push({ text: 'Game Begins', tone: 'start' });
/**
* NO SEED AT A TABLE WITH MORE THAN ONE SEAT (Gitea#20 step 1).
@@ -1105,11 +1117,69 @@ export function submit(game: Game, intent: Intent, as: PlayerIndex | null = null
return false;
}
game.history.push(intent);
/**
* THE HIGH-WATER MARK FOR THIS STEP'S NARRATION (v0.8.0).
*
* Taken here rather than read from `session.ts`'s `sentLines`, which is per-seat and is MUTATED
* by `linesSince()` as a side effect of building a push — so it cannot answer "what did this one
* intent say?". `submit` brackets the whole thing, `record` and `drain` below are the only things
* that append, and the slice after them is exactly this intent's narration including whatever
* automatic phases it drained.
*/
const saidFrom = game.log.length;
record(game, result.events, actor);
drain(game);
collectStep(game.display, game.state, actor, intent.type, game.log.slice(saidFrom));
drainStepping(game);
return true;
}
/**
* `drain()`'s STEPPED TWIN — TODO #18, and the reason this is not just `drain(game)`.
*
* `pump()` runs every automatic phase between one click and the next and `drain()` records the whole
* batch at once, so New Train, the Mainline and the shift change are never drawn at all: trains
* cross the Division in a single jump. Stepping `advance()` one call at a time and collecting after
* each is what gives those phases a visible beat, which is exactly what TODO Reference · #18 says is
* needed — *"a minimum dwell time on its own therefore fixes nothing"*.
*
* IDENTICAL BEHAVIOUR TO `drain()`, deliberately. The same `advance()` calls in the same order
* produce the same state; `record()` is called per phase rather than per batch, which is equivalent
* because `cuesFor` is a pure per-event map with no cross-event state and `record`'s other outputs
* (`scheduled`, `justDrawn`, `announced`) are last-wins in event order either way.
*
* A PHASE THAT DID NOTHING PRODUCES NO STEP. Jesse, 2026-09-09: *"if nothing happens during a phase
* then we shouldn't lose time to it."* Narrating nothing is the test for that — an empty phase adds
* no lines, so it is skipped rather than given a dwell to sit through.
*
* `drain()` itself is untouched, and must stay that way: `fromSave`, `fromMultiplayerSave` and
* `undo` all use it, and the collector staying off those paths is what keeps a replay from
* re-emitting a whole game as steps.
*/
function drainStepping(game: Game): void {
for (let i = 0; i < 10_000; i++) {
const from = game.log.length;
const r = advance(game.state);
record(game, r.events);
/**
* THE TEST IS THE EVENT LIST, NOT THE LOG — and getting that wrong drifted the board.
*
* `record()` deliberately drops `actorChanged` before narrating, so a phase whose only effect is
* handing the turn to the next player grows no lines at all. Collecting only when the log grew
* therefore skipped those, and the last step's frame was then a position behind the real one:
* the animated board ended a turn out of step with the game (`actor: 2` where the game said 1).
*
* A step whose narration is empty still carries the board. It simply costs no time to show —
* `dwellForStep` gives a silent step a dwell of zero — which is the same rule that collapses an
* empty phase, arrived at from the other direction.
*/
if (r.events.length > 0) {
collectStep(game.display, game.state, null, 'phase', game.log.slice(from));
}
if (r.needsInput || game.state.status === 'finished') return;
}
throw new Error('phase driver failed to settle — probable infinite loop');
}
/**
* Which cards in hand can be played RIGHT NOW, in hand order.
*
+260 -60
View File
@@ -24,6 +24,8 @@ import type { NewGameOptions } from './game.ts';
import type { LocalSession, Session } from './session.ts';
import { createLocalSession, createRemoteSession } from './session.ts';
import type { PlayerIndex } from '../engine/state.ts';
import type { PublicDistrict } from '../sim/view.ts';
import { createStepQueue } from './step-queue.ts';
import { notice, prefillCode, runLobby } from './lobby.ts';
import type { LobbyReady } from './lobby.ts';
import {
@@ -67,9 +69,43 @@ type Settings = {
* to?", which Jesse's own framing says is "not something they're likely to need all the time".
*/
gameCardOpen: boolean;
/**
* HOW FAST OTHER PEOPLE'S TURNS PLAY BACK — v0.8.0, TODO #13/#18. A multiplier over the dwell
* table in `sim/pacing.ts`: 1 is as tabled, 0.5 is twice as fast, and **0 turns animation off**,
* which is TODO #18's "a player who has seen it a hundred times will want it off" without a second
* mechanism for it.
*
* Here rather than in the game's config, on Jesse's call 2026-09-09: dwell is presentation, not a
* rule, and a `GameConfig` rides along in saves and replays. It is also per-viewer for the reason
* this whole object exists — two players at one table may reasonably want different speeds.
*/
pace: number;
};
const DEFAULT_SETTINGS: Settings = { districtMode: 'auto', soundOn: false, zoom: 1, gameCardOpen: false };
const DEFAULT_SETTINGS: Settings = { districtMode: 'auto', soundOn: false, zoom: 1, gameCardOpen: false, pace: 1 };
/**
* `?pace=` — a per-session override that persists nothing.
*
* The third of the three tuning levels the design calls for (`docs/plans/jitsi-common-board.md`
* § v0.8.0 § 5): the committed table needs a rebuild, the setting needs a click, and this needs a
* link — which is what makes it the one that is actually useful at a playtest, where two testers can
* be handed different speeds and compared. Follows `?seed=`, which is already the convention here.
*
* Read ONCE, at load. The queue asks for the pace on every step it measures, and `behind()` asks for
* every step still queued — so parsing the query string in there meant building a `URLSearchParams`
* a hundred times to render one row. It cannot change without a reload anyway.
*/
const PACE_OVERRIDE: number | null = (() => {
try {
const raw = new URLSearchParams(location.search).get('pace');
if (raw === null) return null;
const n = Number(raw);
return Number.isFinite(n) && n >= 0 ? n : null;
} catch {
return null;
}
})();
function loadSettings(): Settings {
try {
@@ -87,6 +123,11 @@ function loadSettings(): Settings {
: DEFAULT_SETTINGS.zoom,
gameCardOpen:
typeof parsed.gameCardOpen === 'boolean' ? parsed.gameCardOpen : DEFAULT_SETTINGS.gameCardOpen,
// A negative or non-finite saved value is corrupt, not a request to run time backwards.
pace:
typeof parsed.pace === 'number' && Number.isFinite(parsed.pace) && parsed.pace >= 0
? parsed.pace
: DEFAULT_SETTINGS.pace,
};
} catch {
// A full or disabled localStorage must not take the game down with it — same guard as the save.
@@ -113,6 +154,147 @@ function saveSettings(patch: Partial<Settings>): void {
*/
let session: Session;
/**
* THE ANIMATION QUEUE — v0.8.0, TODO #13/#15/#18.
*
* Holds the board the screen is showing, which is not always the board the game is on. One queue
* for both session kinds: solitaire drains its own collector and a remote session reads the same
* steps off the wire, and this cannot tell which it has (`web/step-queue.ts`).
*
* Reads `pace` through a function rather than a captured value, so changing the setting takes effect
* on the next step instead of the next game. `?pace=` wins over the saved setting for this session
* only.
*/
const stepQueue = createStepQueue(() => PACE_OVERRIDE ?? settings.pace);
/**
* Pulls whatever the session has for us into the queue. Called on every push, before rendering.
*
* A RESET IS TAKEN FIRST AND SEPARATELY: it means "start over from this board", so applying it after
* the steps that arrived with it would draw them onto a baseline they do not chain from.
*/
function drainIntoQueue(): void {
const reset = session.takeDisplayReset();
if (reset) stepQueue.reset(reset);
stepQueue.push(session.takeDisplaySteps());
if (stepQueue.busy()) startAnimationLoop();
}
/**
* WHOSE DISTRICT THE BOARD IS SHOWING — v0.8.0, TODO #13. Null means "your own", drawn exactly as
* it always was.
*
* FOLLOW THE ACTOR (Jesse, 2026-09-09). While the queue is animating, follow the step being shown,
* so a bot's switching turn is watched on the bot's board. At rest, follow whoever the game is
* waiting on — which is how you watch a human opponent work in something close to real time, since
* their steps trickle in as they click rather than arriving in a burst.
*
* `Frame.cells` is the VIEWER'S district and nobody else's, which is the whole reason a step stream
* alone could not answer #13: the data would arrive with nowhere to be drawn. This is where it gets
* drawn — from `PublicDistrict`, which `officeSvg` can render as-is because it takes board data and
* has never needed a private viewer.
*/
function renderWatching(): void {
const behind = stepQueue.behind();
const row = $('watching');
// Collapsed whenever the board is level with the game — which in solitaire is nearly always, and
// between turns in multiplayer too. A row that is always there would be a row nobody reads.
if (behind === 0) {
row.hidden = true;
return;
}
row.hidden = false;
$('watching-behind').textContent = `${behind} behind`;
/**
* THE CAPTION IS #15, and this is where that item lands rather than as a line of its own.
*
* TODO Reference · #15 could not decide the unit — "most recent action" is right in solitaire and
* wrong in multiplayer, where what you missed is everything that happened while you were waiting.
* The queue IS that, so the caption simply names the step being shown, and the counter beside it
* says how much of the wait is left.
*/
const showing = stepQueue.showing();
$('watching-what').textContent = showing?.lines[0]?.text ?? '';
/**
* SKIP COSTS THE ANIMATION AND NEVER THE INFORMATION.
*
* Every line skipped is already in the History panel — the queue animates a board, it does not
* carry the record — which is what makes this safe to press without weighing it up. Assigned each
* render rather than once, matching how every other button on this page is wired.
*/
$('watching-skip').onclick = () => {
if (stepQueue.skip()) render();
};
}
function watchedDistrict(f: Frame): PublicDistrict | null {
const pub = stepQueue.current();
if (!pub) return null;
/**
* FOLLOW WHOEVER IS ACTING. While animating that is the step on screen; at rest it is whoever the
* game is waiting on.
*
* 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.
*/
let player: PlayerIndex | null = f.actor;
if (stepQueue.busy()) {
const acting = stepQueue.showing()?.player;
if (acting !== undefined && acting !== null) player = acting;
}
if (player === null || player === f.viewer) return null;
return pub.districts.find((d) => d.player === player) ?? null;
}
/**
* Drives the queue from the browser's own frame clock, ON DEMAND.
*
* The queue owns no timer of its own — that is what makes it testable without faking one — so
* something has to advance it. This runs only while there is a backlog and stops itself when the
* board catches up, for two reasons beyond tidiness:
*
* - **Loops must not accumulate.** `startAnimationLoop` is reachable from both session kinds, and
* a player can go lobby → game → lobby → game in one page load. A loop started per game and
* never stopped would leave one running per visit, each calling `render()` forever.
* - An idle table should do nothing at all. Solitaire between clicks, and multiplayer between
* turns, is the common case.
*
* `requestAnimationFrame` may be absent — the static build is loaded head-first by `test/web.test.ts`
* against a DOM stub. Nothing here is required for correctness; without it the board simply arrives
* without being animated, which is exactly what `pace = 0` does on purpose.
*/
let animating = false;
function startAnimationLoop(): void {
if (animating || typeof requestAnimationFrame !== 'function') return;
animating = true;
const tick = (now: number): void => {
try {
if (stepQueue.advance(now)) render();
} catch (err) {
/**
* A BROKEN QUEUE MUST NOT TAKE THE GAME WITH IT, or wedge itself on.
*
* `applyPublicDelta` throws when a delta says "unchanged" and there is nothing to merge onto
* — a sender/receiver disagreement about what has been delivered. The board is still correct
* (the authoritative Frame comes down the same push and is drawn from `session.view()`); only
* the animation is lost. Without the flag being cleared here, one throw would leave `animating`
* true forever and no later burst would ever play.
*/
console.error('display queue stopped:', err);
animating = false;
return;
}
if (!stepQueue.busy()) {
animating = false;
// One last render so the "N behind" row collapses the moment the board is level.
renderWatching();
return;
}
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
/**
* The three `Capabilities` (`undo`/`saveLocal`/`newGame`) travel together — all `true` for a
* `LocalSession`, all `false` for a `RemoteSession` (`session.ts`) — so any one of them is a safe
@@ -763,7 +945,8 @@ function beginRemote(ready: LobbyReady, rejoining = false): void {
// real Frame only exists once the SSE connection's first push arrives, so the first render waits
// for `subscribe`'s callback rather than firing immediately (`session.ts`'s own doc comment on
// `createRemoteSession` explains why `view()` would otherwise throw).
session.subscribe(render);
// Steps are pulled in BEFORE the redraw, so a push and the animation it starts land together.
session.subscribe(() => { drainIntoQueue(); render(); });
}
/**
@@ -907,7 +1090,8 @@ function start(): void {
applyCapabilities();
// Every render goes through the session, so the page redraws whenever the game says it changed —
// which is what a remote session will use to push. Locally it fires on each accepted intent.
session.subscribe(render);
// Steps are pulled in BEFORE the redraw, so a push and the animation it starts land together.
session.subscribe(() => { drainIntoQueue(); render(); });
render();
// Coming back to a game is not the same event as being dealt one, and the board looks identical
// either way — mid-Day, mid-phase, with a log already deep (Jesse, 2026-08-30).
@@ -1073,6 +1257,7 @@ function render(): void {
renderTurnChart(f);
renderPresence(f);
renderWatching();
$('revenue').textContent = String(f.revenue);
/**
* THE OBJECTIVE, WITHOUT THE COMMENTARY.
@@ -1139,68 +1324,83 @@ function render(): void {
: 'ATTACH TO THIS CARD';
return [{ row: cell.row, col: cell.col, label }];
});
grid.innerHTML = officeSvg(f.cells, f.runningRow, ghostCoords, legalCaps, f.limits, selectedCrew);
/**
* SOMEBODY ELSE'S BOARD IS READ-ONLY, and that is not a cosmetic distinction.
*
* No ghosts, no legal caps and no selected crew: all three are answers to "what could YOU do
* here", computed from this seat's own menu, and drawing them over another player's district
* would offer moves on a board you cannot play. Every click handler below is skipped for the same
* reason — `spotsAt` holds coordinates in YOUR district, and the same coordinates exist in theirs,
* so wiring them up would silently attach your moves to their squares.
*/
const watched = watchedDistrict(f);
$('districtwho').textContent = watched ? `${watched.name}'s Office Area` : 'Your Office Area';
grid.innerHTML = watched
? officeSvg(watched.cells, watched.runningRow, [], [], watched.limits, null)
: officeSvg(f.cells, f.runningRow, ghostCoords, legalCaps, f.limits, selectedCrew);
applyZoom(grid);
// Wire the roster chips: clicking one sets `selectedCrew`, the same value the "Which train are
// you switching?" picker writes, so the board and the action panel drive one value either way.
for (const g of Array.from(grid.querySelectorAll('g[data-crew]'))) {
const trayId = (g as HTMLElement).dataset['crew'];
if (trayId) (g as unknown as HTMLElement).onclick = () => { selectedCrew = trayId; render(); };
}
// Highlighting rides on top of the drawing: outline the legal squares and make them clickable.
for (const [key, list] of spotsAt) {
const [gr, gc] = key.split(',').map(Number);
const g = grid.querySelector(`g[data-cell="${gr},${gc}"]`);
if (g) {
g.classList.add('bs-legal');
(g as unknown as HTMLElement).onclick = () => pick(key, list);
if (!watched) {
// Wire the roster chips: clicking one sets `selectedCrew`, the same value the "Which train are
// you switching?" picker writes, so the board and the action panel drive one value either way.
for (const g of Array.from(grid.querySelectorAll('g[data-crew]'))) {
const trayId = (g as HTMLElement).dataset['crew'];
if (trayId) (g as unknown as HTMLElement).onclick = () => { selectedCrew = trayId; render(); };
}
}
// Wire the targets. They are already in the SVG, so nothing is re-serialised here.
for (const [key, list] of spotsAt) {
if (f.cells.some((c) => `${c.row},${c.col}` === key)) continue;
const g = grid.querySelector(`g[data-ghost="${key}"]`);
if (g) (g as unknown as HTMLElement).onclick = () => pick(key, list);
}
/**
* THE SWITCHING MOVE, ON THE BOARD.
*
* Every switching decision is about geography — which card the crew can reach, what it will couple
* on the way, whether it can get back — and none of it was drawn: the moves were text buttons
* reading "move to (0, -2)". The classes and the replay viewer have highlighted a position for
* months; the play page simply never used them.
*
* WHERE IT IS, WHERE IT MAY GO, AND WHY NOT THE REST. A blocked card carries its reason in its own
* tooltip, so "why can I not get into that industry?" is answered by hovering the industry.
*/
/**
* ONE CREW'S SQUARES AT A TIME. Drawing every crew's reachable squares at once is worse than
* drawing one crew's: the highlights merge into a single blob and stop meaning "here is where
* THIS train can go", which is the whole reason they are on the board.
*/
const crew = pickedCrew(f);
if (crew && !forPlay) {
// Marked on the crew strip, not the whole card: the Office is the one square a second train may
// share, and outlining the card would claim it belongs to both.
const strip = grid.querySelector(`g[data-cell="${crew.from.row},${crew.from.col}"] .bs-crew`);
if (strip) strip.classList.add('bs-from');
for (const c of crew.to) {
const g = grid.querySelector(`g[data-cell="${c.row},${c.col}"]`);
if (g) g.classList.add('bs-focus');
// Highlighting rides on top of the drawing: outline the legal squares and make them clickable.
for (const [key, list] of spotsAt) {
const [gr, gc] = key.split(',').map(Number);
const g = grid.querySelector(`g[data-cell="${gr},${gc}"]`);
if (g) {
g.classList.add('bs-legal');
(g as unknown as HTMLElement).onclick = () => pick(key, list);
}
}
for (const b of crew.blocked) {
const g = grid.querySelector(`g[data-cell="${b.coord.row},${b.coord.col}"]`);
if (!g) continue;
// Two different things wearing two different marks. A turnout you cannot STOP on is not in
// your way — you run through it — so it must not be drawn like an industry that is locked.
const passable = b.kind === 'noStopping';
g.classList.add(passable ? 'bs-nostop' : 'bs-blocked');
const own = g.getAttribute('data-tip') ?? '';
const head = passable ? 'NO STOPPING HERE' : 'THE CREW CANNOT MOVE HERE';
g.setAttribute('data-tip', `${own}\n\n${head}: ${b.why}`);
// Wire the targets. They are already in the SVG, so nothing is re-serialised here.
for (const [key, list] of spotsAt) {
if (f.cells.some((c) => `${c.row},${c.col}` === key)) continue;
const g = grid.querySelector(`g[data-ghost="${key}"]`);
if (g) (g as unknown as HTMLElement).onclick = () => pick(key, list);
}
/**
* THE SWITCHING MOVE, ON THE BOARD.
*
* Every switching decision is about geography — which card the crew can reach, what it will couple
* on the way, whether it can get back — and none of it was drawn: the moves were text buttons
* reading "move to (0, -2)". The classes and the replay viewer have highlighted a position for
* months; the play page simply never used them.
*
* WHERE IT IS, WHERE IT MAY GO, AND WHY NOT THE REST. A blocked card carries its reason in its own
* tooltip, so "why can I not get into that industry?" is answered by hovering the industry.
*/
/**
* ONE CREW'S SQUARES AT A TIME. Drawing every crew's reachable squares at once is worse than
* drawing one crew's: the highlights merge into a single blob and stop meaning "here is where
* THIS train can go", which is the whole reason they are on the board.
*/
const crew = pickedCrew(f);
if (crew && !forPlay) {
// Marked on the crew strip, not the whole card: the Office is the one square a second train may
// share, and outlining the card would claim it belongs to both.
const strip = grid.querySelector(`g[data-cell="${crew.from.row},${crew.from.col}"] .bs-crew`);
if (strip) strip.classList.add('bs-from');
for (const c of crew.to) {
const g = grid.querySelector(`g[data-cell="${c.row},${c.col}"]`);
if (g) g.classList.add('bs-focus');
}
for (const b of crew.blocked) {
const g = grid.querySelector(`g[data-cell="${b.coord.row},${b.coord.col}"]`);
if (!g) continue;
// Two different things wearing two different marks. A turnout you cannot STOP on is not in
// your way — you run through it — so it must not be drawn like an industry that is locked.
const passable = b.kind === 'noStopping';
g.classList.add(passable ? 'bs-nostop' : 'bs-blocked');
const own = g.getAttribute('data-tip') ?? '';
const head = passable ? 'NO STOPPING HERE' : 'THE CREW CANNOT MOVE HERE';
g.setAttribute('data-tip', `${own}\n\n${head}: ${b.why}`);
}
}
}
+22 -1
View File
@@ -114,6 +114,17 @@ section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
.lb-seat:last-child{border-bottom:none}
.lb-seat .who{flex:1}
#presence{color:#e0b060;font-size:12px;padding:0 14px;empty-cells:hide}
/* WHAT YOU ARE WATCHING — v0.8.0, TODO #13/#15. An IN-FLOW row rather than a floating banner like
#phasenote and #announce: those announce a moment and fade, this one stands for as long as the
board is behind and has a button you have to be able to hit. Amber on the button because amber
already means clickable everywhere else on this page; the row itself stays quiet so it does not
compete with the three banners it sits under. */
#watching{display:flex;align-items:center;gap:10px;padding:4px 14px;font-size:12px;color:#9aa0b4}
#watching[hidden]{display:none}
#watching-what{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.wbehind{font-variant-numeric:tabular-nums;font-weight:700;color:#c9cee0;
background:#22263a;border:1px solid #343a52;border-radius:10px;padding:1px 8px;white-space:nowrap}
#watching-skip{font-size:11px;padding:2px 10px;border-color:#e0b060;color:#e0b060;flex:none}
#presence:empty{display:none}
/* division strip */
#division{display:flex;gap:7px;overflow-x:auto;padding-bottom:4px}
@@ -890,12 +901,22 @@ ul.blocked li{padding:2px 0}
collapsed whenever everyone connected is still connected — a `LocalSession` never fills it. -->
<div id="presence"></div>
<!-- WHAT YOU ARE WATCHING, and how far behind the board is — v0.8.0, TODO #13/#15.
One row rather than three additions: the countdown, the caption naming the action being shown,
and Skip. Empty and collapsed whenever the board is level with the game, which in solitaire is
almost always. -->
<div id="watching" hidden>
<span id="watching-behind" class="wbehind"></span>
<span id="watching-what"></span>
<button id="watching-skip" class="ghost" type="button" title="Stop animating and jump the board to where the game actually is. Nothing is lost — every line is already in the History panel.">Skip</button>
</div>
<main>
<div>
<section><h2>The Division — west to east</h2><div id="division"></div>
<p class="ng-note" id="seating-chain"></p></section>
<section id="district">
<h2>Your Office Area
<h2><span id="districtwho">Your Office Area</span>
<span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span>
<span id="districttoggle" class="seg" role="group" aria-label="When to show your Office Area"><button id="dm-auto" class="ghost" type="button" title="Open during Local Operations and Cargo — the phases that change the district — and folded otherwise.">Auto-hide</button><button id="dm-open" class="ghost" type="button" title="Keep the Office Area open in every phase.">Always show</button><button id="dm-closed" class="ghost" type="button" title="Keep the Office Area folded in every phase. The summary line stays, so it reads as folded rather than missing.">Always hide</button></span>
</h2>
+70 -1
View File
@@ -16,7 +16,10 @@
*/
import type { Intent } from '../engine/intents.ts';
import type { Frame } from '../sim/view.ts';
import type { Frame, PublicFrame } from '../sim/view.ts';
import { publicSnapshot } from '../sim/view.ts';
import { takeSteps } from '../sim/display-step.ts';
import type { DisplayStep } from '../sim/display-step.ts';
import type { PlayerIndex } from '../engine/state.ts';
import { applyDelta } from '../sim/frame-delta.ts';
import type { FrameDelta } from '../sim/frame-delta.ts';
@@ -105,6 +108,28 @@ export type Session = {
* starts — which is exactly when "is everyone here?" is the question.
*/
presence(): { seat: PlayerIndex; connected: boolean; seen: boolean }[];
/**
* ORDERED PRESENTATION STEPS — v0.8.0, TODO #13. What everyone else did, in order, so it can be
* WATCHED rather than discovered.
*
* On the interface rather than on `LocalSession`, which is the whole point: solitaire drains its
* own collector and a remote session reads the same steps off `Push.steps`, so the page animates
* one queue and cannot tell which it has. That is what makes TODO #18 (solitaire's phases flying
* past) and TODO #13 (multiplayer's invisible turns) the same code path.
*
* NOT `steps()` — `LocalSession.steps()` already exists and counts submitted intents for the Undo
* button. Different thing entirely, hence the longer name.
*/
takeDisplaySteps(): DisplayStep[];
/**
* A public frame to start the queue from, once — draining, and non-null only when the queue must
* be RESET rather than advanced.
*
* Steps carry deltas against a chain, so a client with no baseline cannot merge the next one. That
* happens on a first connect, on a reconnect, and locally after an undo or a restore — all of
* which rebuild from scratch. A reset means "throw away what is queued and draw this".
*/
takeDisplayReset(): PublicFrame | null;
/**
* Stop listening, for good.
*
@@ -145,6 +170,12 @@ export type LocalSession = Session & {
*/
export function createLocalSession(seed: number, options?: NewGameOptions): LocalSession {
let game: Game = options ? newGame(seed, configWith(options)) : newGame(seed);
/**
* The baseline the step queue starts from. Set here, and again whenever the game is REPLACED —
* `undo` and `restore` rebuild by replaying history, which (by design) collects no steps, so the
* queue has to be told to start over rather than left holding a chain that no longer continues.
*/
let pendingReset: PublicFrame | null = publicSnapshot(game.state);
const listeners = new Set<() => void>();
const changed = (): void => {
for (const fn of [...listeners]) fn();
@@ -186,6 +217,14 @@ export function createLocalSession(seed: number, options?: NewGameOptions): Loca
},
justDrawn: () => game.justDrawn,
presence: () => [],
// Solitaire's own steps, from the same collector `submit()` fills for every seat of a
// multiplayer game. No separate code path — see `sim/display-step.ts`.
takeDisplaySteps: () => takeSteps(game.display),
takeDisplayReset: () => {
const reset = pendingReset;
pendingReset = null;
return reset;
},
seed: () => game.seed,
save: () => toSave(game),
@@ -199,6 +238,8 @@ export function createLocalSession(seed: number, options?: NewGameOptions): Loca
back.scheduled = null;
back.justDrawn = null;
back.announced = null;
// The rebuilt game has an empty collector and a chain that starts over, so the queue must too.
pendingReset = publicSnapshot(back.state);
changed();
return true;
},
@@ -207,6 +248,7 @@ export function createLocalSession(seed: number, options?: NewGameOptions): Loca
// Restoring replays the whole history and re-records every draw; none of it is news.
game.justDrawn = null;
game.announced = null;
pendingReset = publicSnapshot(game.state);
changed();
},
};
@@ -220,6 +262,10 @@ type Push = {
lines: { text: string; tone: string }[];
/** One entry for a change; every other seat at once on the connect push. */
presence?: { seat: PlayerIndex; connected: boolean; seen: boolean }[];
/** Ordered presentation steps — v0.8.0, identical in every seat's push because they are public. */
steps?: DisplayStep[];
/** The baseline for the step queue, sent on a connect only. */
publicReset?: PublicFrame;
/**
* THE FOUR TRANSIENT SIGNALS, added 2026-08-23.
*
@@ -270,6 +316,8 @@ export function createRemoteSession(
let menu: Menu | null = null;
let lines: { text: string; tone: string }[] = [];
const presence = new Map<PlayerIndex, { connected: boolean; seen: boolean }>();
let displaySteps: DisplayStep[] = [];
let displayReset: PublicFrame | null = null;
let cues: string[] = [];
let scheduled: number | null = null;
let announcement: string | null = null;
@@ -324,6 +372,21 @@ export function createRemoteSession(
if (push.announcement !== undefined && push.announcement !== null) announcement = push.announcement;
// Persists until another draw replaces it, matching the local session's own `justDrawn`.
if (push.justDrawn !== undefined) justDrawnCard = push.justDrawn;
/**
* A RESET DISCARDS WHAT WAS QUEUED, rather than arriving alongside it.
*
* `publicReset` comes on a connect, which is also a RECONNECT — and a reconnecting client's
* queue holds steps whose deltas chain off a baseline the server has since moved past. Merging
* them onto the new baseline would draw a board that never existed. The history panel is what
* carries what was missed; the animation does not replay it (§ v0.8.0).
*/
if (push.publicReset) {
displayReset = push.publicReset;
displaySteps = [];
}
// Accumulated, like cues: two pushes can land between two renders and every step is one thing
// that happened.
if (push.steps) displaySteps = [...displaySteps, ...push.steps];
changed();
};
@@ -376,6 +439,12 @@ export function createRemoteSession(
},
justDrawn: () => justDrawnCard,
presence: () => [...presence].map(([seat, p]) => ({ seat, connected: p.connected, seen: p.seen })),
takeDisplaySteps: () => displaySteps.splice(0, displaySteps.length),
takeDisplayReset: () => {
const reset = displayReset;
displayReset = null;
return reset;
},
close() {
// `reportedGone` first: closing the stream fires `onerror`, and this is a deliberate exit, not
// a game that vanished — `onGone` must not be called and land the page in "that game is no
+120
View File
@@ -0,0 +1,120 @@
/**
* THE ANIMATION QUEUE — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 §§ 4-6.
*
* Holds the public board the screen is currently showing, which is not always the board the game is
* actually on. Steps arrive faster than a person can follow — a bot's whole switching turn lands in
* ONE push, because `driveBots()` plays it out before the push goes back — so this is what turns a
* burst into something watchable. TODO #13.
*
* TRANSPORT-AGNOSTIC ON PURPOSE. It takes `DisplayStep`s and does not care whether they came from
* the engine in this tab or off an SSE stream, which is what lets solitaire (#18: phases that are
* never drawn) and multiplayer (#13: turns you never see) run one implementation. Nothing here
* imports the DOM either, so it is testable without one.
*
* NO TIMERS OF ITS OWN. The caller drives it with `advance(now)` from whatever loop it already has
* — a `requestAnimationFrame`, a test's fake clock. A queue that owned a `setInterval` would need
* starting, stopping and cleaning up on every game replacement, and would be untestable without
* faking timers.
*/
import type { PublicFrame } from '../sim/view.ts';
import type { DisplayStep } from '../sim/display-step.ts';
import { applyPublicDelta } from '../sim/public-delta.ts';
import { dwellForStep } from '../sim/pacing.ts';
export type StepQueue = {
/** Throw away what is queued and show this board — a first connect, a reconnect, an undo. */
reset(frame: PublicFrame): void;
/** Queue steps to be shown in order. */
push(steps: readonly DisplayStep[]): void;
/**
* Show as much as `now` allows. Returns true if the displayed board changed, so a caller can skip
* a redraw when nothing did.
*/
advance(now: number): boolean;
/** Show everything immediately. Returns true if anything was skipped. */
skip(): boolean;
/** The board to draw, or null before any reset has arrived. */
current(): PublicFrame | null;
/**
* How many queued steps the player is still going to WATCH — the number the "N behind" counter
* shows. Not the queue length: see `watchableCount` in `sim/pacing.ts`.
*/
behind(): number;
/** The last step actually shown, for the caption line (#15). Null before anything has been shown. */
showing(): DisplayStep | null;
/** True while there is anything left to show. */
busy(): boolean;
};
/** `pace` is read on every step rather than captured, so changing the setting takes effect at once. */
export function createStepQueue(pace: () => number = () => 1): StepQueue {
let shown: PublicFrame | null = null;
let last: DisplayStep | null = null;
let pending: DisplayStep[] = [];
/** When the step now on screen is due to give way. Null when nothing is waiting. */
let dueAt: number | null = null;
/** Applies one step to the displayed board. A step's delta chains off the previous step's frame. */
const show = (step: DisplayStep): void => {
shown = applyPublicDelta(shown, step.frame);
last = step;
};
return {
reset(frame) {
shown = frame;
pending = [];
dueAt = null;
// `last` deliberately survives: a reconnect should not blank the caption line, and the
// sentence describing the most recent action is still true.
},
push(steps) {
pending.push(...steps);
},
advance(now) {
if (pending.length === 0) {
dueAt = null;
return false;
}
// First step of a burst: show it immediately rather than waiting out a dwell for a board the
// player has not been shown yet.
if (dueAt === null) {
const first = pending.shift()!;
show(first);
dueAt = now + dwellForStep(first, pace());
return true;
}
let drew = false;
/**
* A LOOP, not a single step. A dwell of zero means "do not spend the player's attention on
* this" — bookkeeping, and phases where nothing happened (TODO #18) — so a run of them must
* collapse within one call instead of costing a frame each. The board still passes through
* every state in order; nobody is shown a state that never existed.
*/
while (pending.length > 0 && now >= dueAt) {
const next = pending.shift()!;
show(next);
dueAt = dueAt + dwellForStep(next, pace());
drew = true;
}
if (pending.length === 0 && now >= dueAt) dueAt = null;
return drew;
},
skip() {
if (pending.length === 0) return false;
for (const step of pending) show(step);
pending = [];
dueAt = null;
return true;
},
current: () => shown,
behind: () => pending.filter((s) => dwellForStep(s, pace()) > 0).length,
showing: () => last,
busy: () => pending.length > 0,
};
}
+4 -4
View File
@@ -1679,7 +1679,7 @@ describe('the Crew Tray is a train, and must be made up to leave (§8.2, Appendi
const build = (toNose: boolean): string[] => {
const s = game();
const id = placeTray(s, at(0, 0), [car('boxcar')] as never);
reduce(s, { type: 'carsCoupled', trayId: id, at: at(0, 0), stock: [car('hopper')] as never, from: [], toNose });
reduce(s, { type: 'carsCoupled', player: 0, trayId: id, at: at(0, 0), stock: [car('hopper')] as never, from: [], toNose });
return s.trays.get(id)!.consist.map((c) => c.type);
};
assert.deepEqual(build(true), ['hopper', 'boxcar'], 'running forward takes cars on the nose');
@@ -1694,11 +1694,11 @@ describe('the Crew Tray is a train, and must be made up to leave (§8.2, Appendi
const tray = s.trays.get(id)!;
tray.engineAt = 0;
reduce(s, { type: 'carsCoupled', trayId: id, at: at(0, 0), stock: [car('hopper')] as never, from: [], toNose: true });
reduce(s, { type: 'carsCoupled', player: 0, trayId: id, at: at(0, 0), stock: [car('hopper')] as never, from: [], toNose: true });
assert.equal(tray.engineAt, 1, 'the engine should now have a car ahead of it');
assert.deepEqual(tray.consist.map((c) => c.type), ['hopper', 'boxcar']);
reduce(s, { type: 'carsDropped', trayId: id, at: at(0, 0), stock: [car('hopper')] as never, fromNose: true });
reduce(s, { type: 'carsDropped', player: 0, trayId: id, at: at(0, 0), stock: [car('hopper')] as never, fromNose: true });
assert.equal(tray.engineAt, 0, 'setting out the nose cars puts the engine back in front');
assert.deepEqual(tray.consist.map((c) => c.type), ['boxcar']);
});
@@ -1823,7 +1823,7 @@ describe('the engine is drawn pointing east or west, whatever track it is standi
* So `facing` stays a PORT (movement needs one) and `railFacingOf` is what the board draws.
*/
const moved = (id: string, facing: 'n' | 's' | 'e' | 'w') =>
({ type: 'trayMoved', trayId: id, from: at(0, 0), to: at(0, 0), movesRemaining: 3, facing }) as const;
({ type: 'trayMoved', player: 0, trayId: id, from: at(0, 0), to: at(0, 0), movesRemaining: 3, facing }) as const;
it('carries the east-west sense across north-south track', () => {
const s = game();
+2 -1
View File
@@ -24,12 +24,13 @@ import { impediments, narrate } from '../src/sim/narrate.ts';
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { actionMenu } from '../src/web/game.ts';
import { newCollector } from '../src/sim/display-step.ts';
import type { Game } from '../src/web/game.ts';
/** The thin wrapper `actionMenu` expects, built directly around an already-created multi-player state
* — `newGame` (game.ts) hardcodes one player, so it cannot construct this for a multi-seat game. */
const wrap = (s: GameState): Game =>
({ state: s, seed: s.seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null });
({ state: s, seed: s.seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null, display: newCollector() });
const competitive: GameConfig = {
mode: 'competitive',
+124
View File
@@ -0,0 +1,124 @@
/**
* DWELL BY KIND — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 § 5.
*
* The classification is exhaustive over `Intent['type']` at COMPILE time: `kindOf` declares a
* `StepKind` return and has no `default`, so a new intent breaks the build rather than landing
* silently in a fallback tier. These tests add the part the compiler cannot do — they read the
* intent union out of the source, so the guard survives someone later adding a `default:` that
* would swallow the very thing the exhaustiveness was protecting.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { DWELL, dwellFor, dwellForStep, kindOf, watchableCount } from '../src/sim/pacing.ts';
import type { StepKind } from '../src/sim/pacing.ts';
import type { Intent } from '../src/engine/intents.ts';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
/** Every `type: '…'` literal in the Intent union, read from the source rather than hand-listed. */
function declaredIntents(): string[] {
const src = readFileSync(join(root, 'src/engine/intents.ts'), 'utf8');
return [...new Set([...src.matchAll(/type: '([a-zA-Z.]+)'/g)].map((m) => m[1]!))].sort();
}
const KINDS: StepKind[] = ['switching', 'action', 'phase', 'bookkeeping'];
describe('pacing — dwell by kind', () => {
it('classifies every intent the engine declares', () => {
const declared = declaredIntents();
assert.ok(declared.length > 25, `only found ${declared.length} intents — the parse is wrong`);
for (const intent of declared) {
const kind = kindOf(intent as Intent['type']);
assert.ok(
KINDS.includes(kind),
`${intent} classified as "${kind}", which is not a StepKind — a default case has crept in`,
);
}
});
it('protects switching and collapses bookkeeping', () => {
// The two ends of the measured argument: a switching move is the thing worth watching, and
// `*.end` bookkeeping is over half of a real game's intents.
assert.equal(kindOf('switch.move'), 'switching');
assert.equal(kindOf('switch.dropCars'), 'switching');
assert.equal(kindOf('switch.sortConsist'), 'switching');
assert.equal(kindOf('draw.end'), 'bookkeeping');
assert.equal(kindOf('loadUnload.end'), 'bookkeeping');
assert.equal(kindOf('switch.end'), 'bookkeeping');
assert.equal(kindOf('localOps.choose'), 'bookkeeping');
assert.ok(DWELL.switching > DWELL.action, 'switching must outrank an ordinary action');
assert.equal(DWELL.bookkeeping, 0, 'bookkeeping must cost the player no time at all');
});
it('starts switching at a full second, per the 2026-09-09 decision', () => {
// Jesse: "start at 1s and tune down". Pinned so a later tune is a deliberate edit rather than
// a drift, and so the number in the plan and the number in the code cannot disagree.
assert.equal(DWELL.switching, 1000);
assert.equal(dwellFor('switch.move'), 1000);
});
it('scales with the viewer\'s pace, and 0 turns it off', () => {
assert.equal(dwellFor('switch.move', 1), 1000);
assert.equal(dwellFor('switch.move', 0.5), 500);
assert.equal(dwellFor('switch.move', 2), 2000);
// TODO #18's "a player who has seen it a hundred times will want it off" — no second mechanism.
for (const intent of declaredIntents()) {
assert.equal(dwellFor(intent as Intent['type'], 0), 0, `${intent} still dwells at pace 0`);
}
// A negative pace is a corrupt preference, not a request to run time backwards.
assert.equal(dwellFor('switch.move', -3), 0);
});
it('counts only the steps a player will actually watch', () => {
/**
* The counter's whole point. A backlog of 17 where 12 are bookkeeping must read "5", not "17"
* followed by an instant plummet to 5 — the countdown is meant to be steady enough to decide
* whether to press Skip.
*/
const queue: Intent['type'][] = [
...Array<Intent['type']>(12).fill('draw.end'),
...Array<Intent['type']>(5).fill('switch.move'),
];
assert.equal(queue.length, 17);
assert.equal(watchableCount(queue), 5);
assert.equal(watchableCount(queue, 0), 0, 'with animation off, nothing is behind');
});
it('a silent step beats only when the clock turns over — TODO #18', () => {
/**
* Both obvious rules were wrong, so both are pinned. "No narration, no dwell" flashed past
* phases that moved trains without saying so, killing the very thing #18 asks for. "Anything
* that changed the board" beat on every turn hand-off — `submit()` steps `advance()` about 4.6
* times per intent — which came to a quarter of an hour a game.
*/
const silent = { cause: 'phase' as const, lines: [] as string[] };
assert.equal(dwellForStep({ ...silent, frame: { table: { actor: 2 } } }), 0, 'a turn hand-off shows nothing');
assert.equal(dwellForStep({ ...silent, frame: { table: {} } }), 0, 'a step that changed nothing shows nothing');
assert.equal(dwellForStep({ ...silent, frame: { table: { phase: 'mainline' } } }), DWELL.phase);
assert.equal(dwellForStep({ ...silent, frame: { table: { stage: 4 } } }), DWELL.phase);
// Narration always earns the dwell of whatever caused it, clock or no clock.
assert.equal(
dwellForStep({ cause: 'switch.move', lines: ['moved'], frame: { table: {} } }),
DWELL.switching,
);
});
it('a real switching turn is watchable in a few seconds, not tens of them', () => {
// Six moves is the engine's cap per crew ("N of 6 Moves left"), so this is the worst ordinary
// case for one crew and the arithmetic the design promised: ~6s to watch a whole exercise.
const turn: Intent['type'][] = [
'localOps.choose',
...Array<Intent['type']>(6).fill('switch.move'),
'switch.end',
];
const total = turn.reduce((ms, i) => ms + dwellFor(i), 0);
assert.equal(total, 6000);
assert.equal(watchableCount(turn), 6, 'the choose and the end are not things to watch');
});
});
+186
View File
@@ -0,0 +1,186 @@
/**
* THE SEATLESS PUBLIC DELTA — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 § 3.
*
* The property that matters is RECONSTRUCTION: a receiver that started from one full frame and
* merged every delta since must hold exactly what a fresh `publicSnapshot()` would give it. A delta
* scheme that is merely smaller is worthless if the two sides drift, and the drift would show up as
* a board that is subtly wrong rather than as an error.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { pump } from '../src/engine/advance.ts';
import { createGame } from '../src/engine/setup.ts';
import { legalActions } from '../src/engine/legal.ts';
import type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.ts';
import { applyIntent } from '../src/engine/apply.ts';
import { currentActorOfState, publicSnapshot } from '../src/sim/view.ts';
import type { PublicFrame } from '../src/sim/view.ts';
import { applyPublicDelta, deltaPublicFrame } from '../src/sim/public-delta.ts';
const config: GameConfig = {
mode: 'competitive',
days: 5,
minCombinedRevenue: 0,
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
optionalRules: {
reducedVisibility: false,
employeeRotation: false,
emergencyToolbox: false,
},
};
function newState(seed: number, players = 3, rotation = false): GameState {
const s = createGame({
id: `delta-${seed}`,
seed,
config: rotation
? { ...config, optionalRules: { ...config.optionalRules, employeeRotation: true } }
: config,
playerNames: Array.from({ length: players }, (_, i) => `p${i}`),
});
pump(s);
return s;
}
/** Plays one legal action, preferring a switch move so districts actually change between frames. */
function step(s: GameState, actor: PlayerIndex): boolean {
const options = legalActions(s, actor);
if (options.length === 0) return false;
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
const chosen = move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!;
const r = applyIntent(s, actor, chosen);
if (!r.ok) return false;
pump(s);
return true;
}
describe('public frame delta', () => {
it('reconstructs exactly what a fresh projection produces, over a long chain', () => {
for (const seed of [1917398, 4242]) {
const s = newState(seed);
let sent: PublicFrame | null = null;
let held: PublicFrame | null = null;
let steps = 0;
for (let i = 0; i < 300; i++) {
const actor = currentActorOfState(s);
if (actor === null) break;
if (!step(s, actor)) break;
const next = publicSnapshot(s);
const delta = deltaPublicFrame(sent, next);
held = applyPublicDelta(held, delta);
sent = next;
steps++;
assert.deepEqual(
held,
next,
`merged frame drifted from a fresh projection at step ${steps} (seed ${seed})`,
);
}
assert.ok(steps > 20, `only ${steps} steps for seed ${seed} — the chain proved little`);
}
});
it('sends a district board only when that district changed', () => {
const s = newState(1917398);
const first = publicSnapshot(s);
// Nothing has moved, so a delta against an identical frame must null every board.
const idle = deltaPublicFrame(first, publicSnapshot(s));
assert.equal(idle.division, null, 'the Division was unchanged and must not be resent');
assert.equal(idle.districts.length, 0, 'an unchanged district must be omitted, not sent as nulls');
assert.deepEqual(idle.table, {}, 'an unchanged table must send no fields at all');
// Now move one player. Only that seat's board may be sent — this is the whole point of keying
// the delta by seat rather than comparing `districts` as one array.
let moved: PublicIndexed | null = null;
for (let i = 0; i < 200 && moved === null; i++) {
const actor = currentActorOfState(s);
if (actor === null) break;
const before = publicSnapshot(s);
if (!step(s, actor)) break;
const after = publicSnapshot(s);
const changed = after.districts.filter(
(d) => JSON.stringify(d.cells) !== JSON.stringify(before.districts.find((b) => b.seat === d.seat)?.cells),
);
if (changed.length === 1) moved = { seat: changed[0]!.seat, before, after };
}
assert.ok(moved !== null, 'no single-district change occurred, so this test proved nothing');
const delta = deltaPublicFrame(moved.before, moved.after);
assert.equal(delta.districts.length, 1, 'only the district that changed may be sent');
assert.equal(delta.districts[0]!.seat, moved.seat);
assert.notEqual(delta.districts[0]!.cells, null, 'the district that changed must carry its board');
});
it('a step that changes one field sends one field — the reason this is a partial', () => {
/**
* MEASURED, not assumed. The first version spread the whole frame and nulled only the boards, so
* a step whose sole change was whose turn it is still shipped all 35 top-level properties. Once
* TODO #18 gave automatic phases their own steps, most steps became exactly that, and a full game
* cost 19.4 MB of which 16.7 MB was those. This is the guard against that returning.
*/
const s = newState(1917398);
const before = publicSnapshot(s);
const full = JSON.stringify(deltaPublicFrame(null, before)).length;
// Hand the turn on without touching a board, which is what an automatic phase mostly does.
const after = { ...before, actor: ((before.actor ?? 0) + 1) as PlayerIndex };
const delta = deltaPublicFrame(before, after);
assert.deepEqual(Object.keys(delta.table), ['actor'], 'only the field that changed may be sent');
assert.equal(delta.districts.length, 0);
assert.equal(delta.division, null);
const size = JSON.stringify(delta).length;
assert.ok(size < 120, `a one-field delta serialised to ${size} bytes`);
assert.ok(size * 100 < full, `a one-field delta (${size}B) is not much smaller than a full frame (${full}B)`);
});
it('always carries seat, player and name, so Employee Rotation cannot be missed', () => {
// Rotation moves players between districts, so the seat→player pairing is itself news. Those
// fields are small and are never nulled; the boards they label are what the delta saves.
const s = newState(777, 3, true);
const a = publicSnapshot(s);
// A full frame carries every district, each labelled — that is what a receiver matches on later.
const full = deltaPublicFrame(null, a);
assert.equal(full.districts.length, a.districts.length);
for (const d of full.districts) {
assert.equal(typeof d.seat, 'number');
assert.equal(typeof d.player, 'number');
assert.ok(typeof d.name === 'string' && d.name.length > 0, 'every district must stay labelled');
}
// And a district sent at all always carries its labels, even when only its board moved: rotation
// makes the seat→player pairing news in its own right.
const rotated = { ...a, districts: a.districts.map((d, i) => (i === 0 ? { ...d, player: ((d.player + 1) % 3) as PlayerIndex } : d)) };
const delta = deltaPublicFrame(a, rotated);
assert.equal(delta.districts.length, 1, 'a relabelled district must be sent even with no board change');
assert.equal(typeof delta.districts[0]!.player, 'number');
});
it('a first frame is sent whole', () => {
const s = newState(4242);
const full = deltaPublicFrame(null, publicSnapshot(s));
assert.notEqual(full.division, null);
for (const d of full.districts) {
assert.notEqual(d.cells, null, `seat ${d.seat} must be sent in full on a first frame`);
assert.notEqual(d.facilities, null);
}
// And it merges with no previous frame at all.
assert.deepEqual(applyPublicDelta(null, full), publicSnapshot(s));
});
it('refuses to merge an "unchanged" board it has nothing to merge onto', () => {
// A sender whose bookkeeping has drifted would otherwise hand a player a blank district.
const s = newState(4242);
const a = publicSnapshot(s);
const unchanged = deltaPublicFrame(a, publicSnapshot(s));
assert.throws(() => applyPublicDelta(null, unchanged), /no previous frame to merge onto/);
});
});
type PublicIndexed = { seat: number; before: PublicFrame; after: PublicFrame };
+101 -13
View File
@@ -244,11 +244,67 @@ describe('redaction — the shared narration log never carries a seat\'s secrets
describe('#91 — nothing private survives serialisation, in any state', () => {
const names = ['Ann', 'Bob', 'Cy'];
/** Everything one seat can see, as one string: their Frame, the public board, and their lines. */
const everythingSeatSees = (g: ReturnType<typeof newMultiplayerGame>, seat: PlayerIndex): string =>
JSON.stringify(snapshot(g.state, g.log, null, null, null, false, seat)) +
'\n' + JSON.stringify(publicSnapshot(g.state)) +
'\n' + g.log.map((l) => l.text).join('\n');
/**
* Everything one seat can see, split into the two halves the checks below treat differently.
*
* `structural` is the machine-readable state: their Frame, the public board, and the frame of every
* presentation step they are sent (v0.8.0, TODO #13). `narration` is what the table was TOLD.
*
* Steps are folded in here rather than given a test of their own so every case below covers them:
* the blind draw, the pending decision, Employee Rotation before and after the seating moves, and
* the played-out game. Their `lines` are a slice of `g.log` by construction, so the log covers the
* narration half of a step and does not need to be searched twice.
*/
const everythingSeatSees = (g: ReturnType<typeof newMultiplayerGame>, seat: PlayerIndex): {
structural: string;
history: string;
narration: string[];
} => ({
/**
* `[]` for the Frame's own lines, MATCHING PRODUCTION. `frameFor()` (`server/session.ts`) has
* passed no log since #97 — narration goes out incrementally through `Push.lines` instead — so
* embedding it here audits a path that no longer exists, and worse, it puts the whole log inside
* `structural` where the face-up-pile rule below cannot reach it. The log is audited in full as
* `narration`; this is a de-duplication, not a relaxation.
*/
structural:
JSON.stringify(snapshot(g.state, [], null, null, null, false, seat)) +
'\n' + JSON.stringify(publicSnapshot(g.state)),
/**
* THE STEP FRAMES ARE A RECORD OF WHAT WAS PUBLIC OVER TIME, not a view of the position now —
* so they get the PRECISE check and not the fuzzy one, for the same reason the face-up-pile
* lines do.
*
* Every one is built by `deltaPublicFrame` over `publicSnapshot`, which the allow-list test at
* the bottom of this file pins property by property; that is what guarantees a step frame is
* clean. Searching their accumulation for a card NAME asks "was this ever public?" and answers
* a question nobody was posing: Train 6 sat face-up in a Department at step 40 and is in Ann's
* hand at step 120, and both facts are correct. A card ID is different — narration never renders
* one and no public field carries an opponent's, so finding one anywhere is still proof.
*/
history: JSON.stringify(g.display.steps.map((step) => step.frame)),
narration: g.log.map((l) => l.text),
});
/**
* A FACE-UP PILE IS ALLOWED TO NAME THE CARD ON IT, and the log is history rather than a view.
*
* §2.6: the three Department piles and the Salvage Yard are face up, "so players can audit
* discards" — a discard goes onto one precisely so a rival can take it. So "Player Ann discarded
* Train 6 face-up on top of Department 3" is the record working, and it stays in the log after Ann
* takes the card back into her hand. The name-based check below would otherwise read that historical
* line as proof of what Ann is holding NOW, which is how it reported a leak against correct code on
* seed 1917398.
*
* These lines are excluded from the NAME check only. The card-id check and the seed check still run
* over them, because those are precise: an id is unique, so finding one is proof, and narration
* never renders a raw id.
*
* **This does not weaken the blind-draw detection**, which is the leak this whole net was built
* for (v0.7.9.2, "Red Flags"): a blind draw names the HOME OFFICE DECK, which is face down and
* matches nothing here.
*/
const namesAFaceUpPile = (line: string): boolean => /Department|Salvage/i.test(line);
/**
* Every secret belonging to somebody OTHER than `seat`: their card ids, and the names those ids
@@ -265,8 +321,14 @@ describe('#91 — nothing private survives serialisation, in any state', () => {
* This is what caught the blind-draw leak in v0.7.9.2: "Red Flags" was in exactly one hand, and it
* was in the log.
*/
const secretsOfOthers = (g: ReturnType<typeof newMultiplayerGame>, seat: PlayerIndex): { what: string; value: string }[] => {
const out: { what: string; value: string }[] = [];
const secretsOfOthers = (
g: ReturnType<typeof newMultiplayerGame>,
seat: PlayerIndex,
): { what: string; value: string; precise: boolean }[] => {
// `precise` marks evidence that is proof on its own — a card id is unique, so finding one
// anywhere is a leak. A NAME is circumstantial and is searched over a narrower string; see
// `namesAFaceUpPile`.
const out: { what: string; value: string; precise: boolean }[] = [];
// How many cards in the whole game carry each name, and how many of those are in a given hand.
const totalByName = new Map<string, number>();
for (const id of g.state.cards.keys()) {
@@ -282,10 +344,10 @@ describe('#91 — nothing private survives serialisation, in any state', () => {
heldByName.set(n, (heldByName.get(n) ?? 0) + 1);
}
for (const id of hand) {
out.push({ what: `${p.name}'s card id`, value: id });
out.push({ what: `${p.name}'s card id`, value: id, precise: true });
const name = cardName(g.state, id);
if (totalByName.get(name) === heldByName.get(name)) {
out.push({ what: `${p.name}'s card name, unique to their hand`, value: name });
out.push({ what: `${p.name}'s card name, unique to their hand`, value: name, precise: false });
}
}
}
@@ -295,15 +357,19 @@ describe('#91 — nothing private survives serialisation, in any state', () => {
/** Runs the whole net over one state, and says which state failed if it does. */
const audit = (g: ReturnType<typeof newMultiplayerGame>, where: string): void => {
for (const seat of g.state.players.map((p) => p.index)) {
const seen = everythingSeatSees(g, seat);
for (const { what, value } of secretsOfOthers(g, seat)) {
const { structural, history, narration } = everythingSeatSees(g, seat);
const everything = structural + '\n' + history + '\n' + narration.join('\n');
// Names are fuzzy evidence, so they are searched everywhere EXCEPT the lines a face-up pile
// is entitled to name a card on. Ids are precise and are searched everywhere.
const forNames = structural + '\n' + narration.filter((l) => !namesAFaceUpPile(l)).join('\n');
for (const { what, value, precise } of secretsOfOthers(g, seat)) {
assert.ok(
!seen.includes(value),
!(precise ? everything : forNames).includes(value),
`${where}: seat ${seat} can see ${what} ("${value}")`,
);
}
// The seed is the whole future of the deal and must not reach a seat by any route.
assert.ok(!seen.includes(String(g.seed)), `${where}: seat ${seat} can see the seed ${g.seed}`);
assert.ok(!everything.includes(String(g.seed)), `${where}: seat ${seat} can see the seed ${g.seed}`);
}
// And the spectator board, which has no seat and is therefore entitled to nothing private.
const pub = JSON.stringify(publicSnapshot(g.state));
@@ -346,6 +412,28 @@ describe('#91 — nothing private survives serialisation, in any state', () => {
audit(g, 'after a blind draw');
});
it('the net actually sees the presentation steps it claims to cover (v0.8.0)', () => {
/**
* Guards the COVERAGE, not the code. `everythingSeatSees` folds `display.steps` into the string
* every case above is audited against — which is worth nothing if that array is empty in
* practice. So: play a real game, and assert both that steps accumulated and that the audited
* string contains them.
*/
const g = newMultiplayerGame(1917398, config, names);
play(g, 120);
assert.ok(g.display.steps.length > 20, `only ${g.display.steps.length} steps — the net covers little`);
const { history, narration } = everythingSeatSees(g, 0 as PlayerIndex);
assert.ok(
history.includes(JSON.stringify(g.display.steps.map((step) => step.frame))),
'the audited string does not actually contain the step frames',
);
// And a step's own narration is a slice of the log, so the log half covers it.
const fromSteps = g.display.steps.flatMap((step) => step.lines.map((l) => l.text));
assert.ok(fromSteps.length > 0, 'the steps carried no narration to cover');
assert.ok(fromSteps.every((t) => narration.includes(t)), 'a step said something the log did not');
audit(g, 'a played game with presentation steps');
});
it('mid-game, with real hands and a built board', () => {
// A DISTINCTIVE seed, deliberately. Seed 7 makes the seed check meaningless — "7" is in "Train
// 7", in every coordinate and in half the numbers on the board — so it reported a leak that was
+56 -9
View File
@@ -6,6 +6,9 @@
*/
import { describe, it } from 'node:test';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import assert from 'node:assert/strict';
import { pump } from '../src/engine/advance.ts';
@@ -42,9 +45,9 @@ const SAMPLES: GameEvent[] = [
{ type: 'phaseBegan', phase: 'mainline' },
{ type: 'actorChanged', player: 0 },
{ type: 'localOpsOptionChosen', player: 0, option: 'switch' },
{ type: 'trayMoved', trayId: 't0', from: { row: 0, col: 0 }, to: { row: 0, col: 1 }, movesRemaining: 5 },
{ type: 'carsCoupled', trayId: 't0', at: { row: 0, col: 1 }, stock: [{ type: 'hopper', loaded: false }], from: [{ row: 0, col: 1 }], toNose: true },
{ type: 'carsDropped', trayId: 't0', at: { row: 1, col: 0 }, stock: [{ type: 'hopper', loaded: false }] },
{ 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 },
{ type: 'carsDropped', player: 0, trayId: 't0', at: { row: 1, col: 0 }, stock: [{ type: 'hopper', loaded: false }] },
{ type: 'cardDrawn', player: 0, source: 'homeOffice', cardId: 'c1' },
{ type: 'cardPlayed', player: 0, cardId: 'c1', placement: { row: 1, col: 0 }, variant: 0 },
{ type: 'officeUpgraded', player: 0, from: 'whistlePost', to: 'depot' },
@@ -72,12 +75,56 @@ const SAMPLES: GameEvent[] = [
describe('narration', () => {
it('covers every event type the engine can emit', () => {
// Guards against a new event type slipping in unnarrated.
const covered = new Set(SAMPLES.map((e) => e.type));
const declared = new Set<string>();
for (const e of SAMPLES) declared.add(e.type);
assert.equal(covered.size, 30, 'sample list is out of step with GameEvent');
assert.equal(declared.size, 30);
/**
* THIS TEST USED TO BUILD BOTH SETS FROM `SAMPLES` and compare them to each other, so it could
* only ever assert that the sample list had 30 distinct entries — the one thing it could not
* detect was the thing its comment promised, a new `GameEvent` slipping in unnarrated. Fixed
* 2026-09-09 while adding the v0.8.0 step collector, which made the event union load-bearing for
* a second reader.
*
* The union is read out of `src/engine/events.ts` rather than hand-listed, the same way
* `test/pacing.test.ts` reads the intent union: a list maintained by hand is a list that goes
* stale, which is how this got here.
*/
const here = dirname(fileURLToPath(import.meta.url));
const declared = new Set(
[...readFileSync(join(here, '../src/engine/events.ts'), 'utf8').matchAll(/type: '([a-zA-Z]+)'/g)]
.map((m) => m[1]!),
);
const narrated = new Set(
[...readFileSync(join(here, '../src/sim/narrate.ts'), 'utf8').matchAll(/case '([a-zA-Z]+)':/g)]
.map((m) => m[1]!),
);
assert.ok(declared.size > 40, `only ${declared.size} event types parsed — the parse is wrong`);
// THE INVARIANT THAT MATTERS: an event the engine can emit and `narrate` has no case for falls
// through to a placeholder, in front of a player. This is the check the old version promised.
const unnarrated = [...declared].filter((t) => !narrated.has(t));
assert.deepEqual(unnarrated, [], 'these event types can be emitted and have no narration case');
const covered = new Set<string>(SAMPLES.map((e) => e.type));
const unknown = [...covered].filter((t) => !declared.has(t));
assert.deepEqual(unknown, [], 'these samples name an event the engine no longer declares');
/**
* THE KNOWN GAP, PINNED SO IT CANNOT GROW.
*
* `SAMPLES` exercises the TEXT of 30 of the 55 declared events; the other 25 have a narration
* case (checked above) but no sample, so nothing proves their sentence is any good. Found
* 2026-09-09 — the old test built both of its sets from `SAMPLES` and compared them to each
* other, so it could only ever assert that the sample list had 30 distinct entries, and the one
* thing it could not detect was the thing its comment promised.
*
* Pinned rather than fixed: writing 25 fixtures is a job of its own, and a bad sentence is worth
* finding deliberately rather than in a rush. What this does guarantee is that a NEW event type
* cannot join the unsampled set silently.
*/
const unsampled = [...declared].filter((t) => !covered.has(t)).sort();
assert.equal(
unsampled.length,
25,
`the unsampled set changed (${unsampled.length}): add a sample for a new event, or update this count`,
);
});
it('gives every event a specific, non-empty sentence', () => {
+204
View File
@@ -0,0 +1,204 @@
/**
* THE ANIMATION QUEUE — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 §§ 5-6.
*
* Driven against REAL steps from a real game rather than hand-built fixtures, because the properties
* that matter are about what actual play produces: a bot's whole switching turn arriving in one
* burst, and a backlog that is mostly bookkeeping.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { legalActions } from '../src/engine/legal.ts';
import type { GameConfig } from '../src/engine/state.ts';
import { currentActor, newMultiplayerGame, submit } from '../src/web/game.ts';
import { publicSnapshot } from '../src/sim/view.ts';
import { takeSteps } from '../src/sim/display-step.ts';
import type { DisplayStep } from '../src/sim/display-step.ts';
import { createStepQueue } from '../src/web/step-queue.ts';
import { DWELL } from '../src/sim/pacing.ts';
const config: GameConfig = {
mode: 'competitive',
days: 5,
minCombinedRevenue: 0,
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
optionalRules: {
reducedVisibility: false,
employeeRotation: false,
emergencyToolbox: false,
},
};
/**
* Plays a real game and returns its steps, preferring switch moves so a burst actually occurs.
*
* 400 moves, not 120: switching is not legal until there is track laid and a train in the district,
* and on this seed the first `switch.move` is at move 144. A shorter run produces a queue with no
* switching in it at all, which would make the pacing assertions here vacuous.
*/
function realSteps(seed: number, moves: number): { steps: DisplayStep[]; final: ReturnType<typeof publicSnapshot> } {
const game = newMultiplayerGame(seed, config, ['Alice', 'Bob', 'Carol']);
takeSteps(game.display);
const steps: DisplayStep[] = [];
for (let i = 0; i < moves; i++) {
const actor = currentActor(game);
if (actor === null) break;
const options = legalActions(game.state, actor);
if (options.length === 0) break;
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
if (!submit(game, move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
steps.push(...takeSteps(game.display));
}
return { steps, final: publicSnapshot(game.state) };
}
/** The baseline a queue starts from, matching what a connect push carries. */
function baseline(seed: number): ReturnType<typeof publicSnapshot> {
const game = newMultiplayerGame(seed, config, ['Alice', 'Bob', 'Carol']);
return publicSnapshot(game.state);
}
describe('the step queue', () => {
it('shows the whole burst in order and lands on the real board', () => {
const { steps, final } = realSteps(1917398, 400);
assert.ok(steps.length > 30, `only ${steps.length} steps — this proved little`);
const q = createStepQueue();
q.reset(baseline(1917398));
q.push(steps);
// Run a clock forward until it settles, in 50ms ticks like a render loop would.
let now = 0;
for (let i = 0; i < 20_000 && q.busy(); i++) {
q.advance(now);
now += 50;
}
assert.equal(q.busy(), false, 'the queue never drained');
assert.deepEqual(q.current(), final, 'the animated board did not land on the real one');
assert.equal(q.showing()?.seq, steps[steps.length - 1]!.seq, 'the caption is not on the last step');
});
it('a burst of switching takes real time, and bookkeeping takes none', () => {
const { steps } = realSteps(1917398, 400);
const q = createStepQueue();
q.reset(baseline(1917398));
// Only the bookkeeping: it must all collapse into a single advance.
const bookkeeping = steps.filter((s) => s.cause.endsWith('.end') || s.cause === 'localOps.choose');
assert.ok(bookkeeping.length > 10, 'not enough bookkeeping steps to prove the collapse');
q.push(bookkeeping);
q.advance(0);
q.advance(0);
assert.equal(q.busy(), false, `${bookkeeping.length} bookkeeping steps should cost no time at all`);
// And switching: each one must hold the screen.
const switching = steps.filter((s) => s.cause.startsWith('switch.') && s.cause !== 'switch.end');
assert.ok(switching.length >= 6, `only ${switching.length} switching steps found`);
const q2 = createStepQueue();
q2.reset(baseline(1917398));
q2.push(switching.slice(0, 6));
q2.advance(0);
assert.equal(q2.behind(), 5, 'the first is shown at once; five are still to watch');
q2.advance(DWELL.switching - 1);
assert.equal(q2.behind(), 5, 'a switching move must not be replaced early');
q2.advance(DWELL.switching);
assert.equal(q2.behind(), 4, 'and must be replaced once its dwell is up');
});
it('counts only what will be watched, so the countdown is steady', () => {
// The counter's whole purpose: a backlog of mostly-bookkeeping must not read as a huge number
// that collapses the instant it starts.
const { steps } = realSteps(1917398, 400);
const q = createStepQueue();
q.reset(baseline(1917398));
q.push(steps);
const behind = q.behind();
assert.ok(behind > 0 && behind < steps.length, `behind ${behind} of ${steps.length} queued`);
q.advance(0);
let ticks = 0;
let previous = q.behind();
let now = 0;
while (q.busy() && ticks++ < 20_000) {
now += 50;
q.advance(now);
const nowBehind = q.behind();
assert.ok(nowBehind <= previous, 'the counter must never go up while draining');
previous = nowBehind;
}
assert.equal(q.behind(), 0);
});
it('skip jumps to the real board without losing a single state on the way', () => {
const { steps, final } = realSteps(1917398, 400);
const q = createStepQueue();
q.reset(baseline(1917398));
q.push(steps);
q.advance(0);
assert.equal(q.skip(), true, 'there was a backlog to skip');
assert.equal(q.busy(), false);
assert.equal(q.behind(), 0);
// Skip applies every delta rather than jumping the chain, so the board is exact.
assert.deepEqual(q.current(), final, 'skipping produced a board the game was never in');
assert.equal(q.skip(), false, 'skipping an empty queue changes nothing');
});
it('pace 0 turns animation off entirely — TODO #18', () => {
const { steps, final } = realSteps(1917398, 400);
const q = createStepQueue(() => 0);
q.reset(baseline(1917398));
q.push(steps);
// One advance at a single instant must consume everything: nothing dwells at all.
q.advance(0);
q.advance(0);
assert.equal(q.busy(), false, 'with animation off, nothing may be left waiting');
assert.equal(q.behind(), 0, 'nothing is "behind" when nothing is being animated');
assert.deepEqual(q.current(), final);
});
it('pace scales the wait without changing the order', () => {
const { steps } = realSteps(1917398, 400);
const switching = steps.filter((s) => s.cause.startsWith('switch.') && s.cause !== 'switch.end').slice(0, 3);
assert.equal(switching.length, 3);
const half = createStepQueue(() => 0.5);
half.reset(baseline(1917398));
half.push(switching);
half.advance(0);
half.advance(DWELL.switching / 2);
assert.equal(half.behind(), 1, 'at half pace, half the dwell should have advanced one step');
});
it('a reset discards the backlog rather than merging it onto a new baseline', () => {
/**
* A reconnecting client holds steps whose deltas chain off a baseline the server has moved past.
* Merging them onto the new one would draw a board that never existed — and `applyPublicDelta`
* would throw the moment a "null means unchanged" field had nothing to merge onto.
*/
const { steps, final } = realSteps(1917398, 400);
const q = createStepQueue();
q.reset(baseline(1917398));
q.push(steps.slice(0, 10));
q.advance(0);
assert.ok(q.busy());
q.reset(final);
assert.equal(q.busy(), false, 'a reset must empty the queue');
assert.equal(q.behind(), 0);
assert.deepEqual(q.current(), final);
// And the caption survives: a reconnect should not blank the "what just happened" line.
assert.ok(q.showing() !== null, 'the caption should survive a reset');
});
it('draws nothing before a reset has arrived', () => {
const q = createStepQueue();
assert.equal(q.current(), null);
assert.equal(q.advance(0), false);
assert.equal(q.behind(), 0);
assert.equal(q.showing(), null);
});
});
+311
View File
@@ -0,0 +1,311 @@
/**
* THE WATCHABLE TABLE — v0.8.0, Gitea#20 / TODO #13, #15, #18.
*
* One shared, ordered presentation of everyone else's turns, on a seated player's own screen. The
* design is `docs/plans/jitsi-common-board.md` § v0.8.0; this file is its tests.
*
* Starting with ATTRIBUTION, because the caption row and the history panel both read these lines
* and a line that does not say who acted is useless on a screen built to answer "what did they
* just do?".
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { applyIntent } from '../src/engine/apply.ts';
import { legalActions } from '../src/engine/legal.ts';
import type { GameConfig, PlayerIndex } from '../src/engine/state.ts';
import { fromMultiplayerSave, newGame, newMultiplayerGame, submit } from '../src/web/game.ts';
import { currentActor } from '../src/web/game.ts';
import { applyPublicDelta } from '../src/sim/public-delta.ts';
import { publicSnapshot } from '../src/sim/view.ts';
import type { PublicFrame } from '../src/sim/view.ts';
import { takeSteps } from '../src/sim/display-step.ts';
import { createSession } from '../src/server/session.ts';
import { kindOf } from '../src/sim/pacing.ts';
const config: GameConfig = {
mode: 'competitive',
days: 5,
minCombinedRevenue: 0,
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
optionalRules: {
reducedVisibility: false,
employeeRotation: false,
emergencyToolbox: false,
},
};
/**
* The four events a switching turn is made of. Every one of them used to arrive in the shared log
* unattributed: `record()` (`web/game.ts`) prefixes a line with the player's name only when the
* event itself carries `player`, and these four were the only events in their class that did not
* — `cardDrawn`, `cardPlayed`, `cardDiscarded`, `carPlacedOnTrain`, `loadStarted`, `loadCompleted`,
* `flyingSwitch` and `localOpsOptionChosen` all did. So a switching turn read as an attributed
* bracket around anonymous contents:
*
* Player Alice chose to switch ← attributed
* CREW moved (1,2) → (1,3) — 4 of 6 ← whose train?
* Player Alice finished Local Operations ← attributed
*
* Measured 2026-09-09 and fixed with the feature that reads them, not filed.
*/
const SWITCHING_EVENTS = ['trayMoved', 'carsCoupled', 'carsDropped', 'consistSorted'] as const;
/** How each of those four reads in the log, so the assertions can find them by text. */
const SWITCHING_LINE = /^Player .+ (moved (Train |the local crew)|coupled \d+ car|set out |used the SMALL YARD)/;
describe('switching is attributed — TODO #13', () => {
it('every switching event carries the player who acted', () => {
/**
* Driven by PREFERRING switch moves rather than taking the first legal action, because bot
* switching is clustered rather than spread: two of the three published replays contain no
* `switch.move` at all, so a game driven by `options[0]` can finish without ever exercising
* this. The counter below then guards against the test passing vacuously.
*/
let seen = 0;
for (const seed of [1917398, 191056, 4242]) {
const game = newMultiplayerGame(seed, config, ['Alice', 'Bob', 'Carol']);
for (let i = 0; i < 800; i++) {
const actor = currentActor(game);
if (actor === null) break;
const options = legalActions(game.state, actor);
if (options.length === 0) break;
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
const chosen = move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!;
// Read the events this intent produces before applying it for real, so the assertion sees
// exactly what `record()` will be handed.
const preview = applyIntent(structuredClone(game.state), actor, chosen);
if (preview.ok) {
for (const e of preview.events) {
if ((SWITCHING_EVENTS as readonly string[]).includes(e.type)) {
assert.ok(
'player' in e,
`${e.type} carries no player, so the log cannot say whose crew it was`,
);
assert.equal(
(e as { player: PlayerIndex }).player,
actor,
`${e.type} names the wrong player`,
);
seen++;
}
}
}
if (!submit(game, chosen)) break;
}
}
assert.ok(seen > 0, 'no switching event was produced, so this test proved nothing');
});
it('reads as a player action in the log, not as anonymous plain text', () => {
let lines = 0;
for (const seed of [1917398, 4242]) {
const game = newMultiplayerGame(seed, config, ['Alice', 'Bob', 'Carol']);
for (let i = 0; i < 800; i++) {
const actor = currentActor(game);
if (actor === null) break;
const options = legalActions(game.state, actor);
if (options.length === 0) break;
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
if (!submit(game, move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
}
for (const line of game.log) {
// The old wording. `uncapitalise` deliberately leaves an acronym alone (`^[A-Z][a-z]` only),
// so "CREW moved" and "SMALL YARD —" would have survived the prefix and read as
// "Player Alice CREW moved …". Both were reworded to compose.
assert.doesNotMatch(
line.text,
/^CREW moved|^SMALL YARD —/,
`an unattributed switching line survived: ${line.text}`,
);
if (SWITCHING_LINE.test(line.text)) {
assert.equal(line.tone, 'act', `a switching line must read as somebody's move: ${line.text}`);
lines++;
}
}
}
assert.ok(lines > 0, 'no switching line reached the log, so this test proved nothing');
});
});
describe('the display-step collector — TODO #13', () => {
it('emits one step per accepted intent plus one per automatic phase, in order', () => {
const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']);
let accepted = 0;
for (let i = 0; i < 120; i++) {
const actor = currentActor(game);
if (actor === null) break;
const options = legalActions(game.state, actor);
if (options.length === 0) break;
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
if (!submit(game, move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
accepted++;
}
assert.ok(accepted > 30, `only ${accepted} intents accepted — this proved little`);
const steps = takeSteps(game.display);
/**
* TWO KINDS OF STEP SINCE TODO #18: one per accepted intent, and one per automatic phase that
* did anything. So the count is no longer `accepted` — but every intent must still have exactly
* one step, which is the invariant that matters.
*/
const byIntent = steps.filter((s) => s.cause !== 'phase');
const byPhase = steps.filter((s) => s.cause === 'phase');
assert.equal(byIntent.length, accepted, 'one step per accepted intent, no more and no fewer');
assert.ok(byPhase.length > 0, 'no phase produced a step — TODO #18 is not being served');
steps.forEach((s, i) => {
assert.equal(s.seq, i, 'sequence numbers must be dense and in order');
assert.equal(s.protocolVersion, 1);
assert.ok(kindOf(s.cause), `step ${i} carries a cause pacing cannot classify`);
// A phase is nobody's move; an intent is always somebody's.
assert.equal(s.player === null, s.cause === 'phase', `step ${i} disagrees about who acted`);
assert.equal(s.seat === null, s.cause === 'phase');
});
assert.equal(takeSteps(game.display).length, 0, 'draining must empty the collector');
});
it('a rejected intent produces no step', () => {
const game = newMultiplayerGame(4242, config, ['Alice', 'Bob', 'Carol']);
takeSteps(game.display);
// Somebody else's turn: refused before the engine is touched, so nothing to present.
const notMyTurn = ((currentActor(game) ?? 0) + 1) % 3;
assert.equal(submit(game, { type: 'draw.end' }, notMyTurn as PlayerIndex), false);
assert.equal(takeSteps(game.display).length, 0, 'a refused intent must not be presented');
});
it('the step deltas reconstruct the public board exactly', () => {
const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']);
let held: PublicFrame | null = null;
for (let i = 0; i < 150; i++) {
const actor = currentActor(game);
if (actor === null) break;
const options = legalActions(game.state, actor);
if (options.length === 0) break;
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
if (!submit(game, move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
for (const s of takeSteps(game.display)) held = applyPublicDelta(held, s.frame);
}
assert.deepEqual(held, publicSnapshot(game.state), 'the animated board drifted from the real one');
});
/**
* THE PROPERTY THAT IS CURRENTLY FREE AND MUST STAY THAT WAY.
*
* `fromSave`/`fromMultiplayerSave` rebuild a game with `applyIntent` + `record` + `drain` rather
* than `submit`, so a resumed server does not re-emit the whole game as steps and burn the
* sequence. The plan expected this to need an explicit guard. It does not — but move a replay
* path onto `submit()` and it silently becomes a real bug, which is why this is pinned.
*/
it('replaying a save emits no steps at all', () => {
const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']);
for (let i = 0; i < 80; i++) {
const actor = currentActor(game);
if (actor === null) break;
const options = legalActions(game.state, actor);
if (options.length === 0) break;
if (!submit(game, options[0]!)) break;
}
assert.ok(game.history.length > 20, 'need a real history to replay');
const rebuilt = fromMultiplayerSave(game.seed, config, ['Alice', 'Bob', 'Carol'], game.history);
assert.equal(
rebuilt.game.display.steps.length,
0,
'a replay re-emitted the whole game as display steps',
);
assert.equal(rebuilt.game.display.seq, 0, 'a replay burned display sequence numbers');
});
it('solitaire collects the same way multiplayer does', () => {
// The standing design direction: solitaire is a special case of multiplayer, not a second
// implementation. Both go through one `submit()`, so this needs no separate code path — and
// that is exactly what makes TODO #18 fall out of TODO #13's mechanism.
const game = newGame(4242);
let accepted = 0;
for (let i = 0; i < 60; i++) {
const actor = currentActor(game);
if (actor === null) break;
const options = legalActions(game.state, actor);
if (options.length === 0) break;
if (!submit(game, options[0]!)) break;
accepted++;
}
assert.ok(accepted > 10, 'the solitaire game did not get going');
const collected = takeSteps(game.display);
assert.equal(
collected.filter((s) => s.cause !== 'phase').length,
accepted,
'solitaire must collect a step per intent too',
);
// And solitaire is where TODO #18 lives — its phases must earn beats on the same path.
assert.ok(collected.some((s) => s.cause === 'phase'), 'solitaire got no phase steps');
});
});
describe('steps reach a seated player — TODO #13', () => {
it('never replays the opening bot turns at the first client to connect', () => {
/**
* `buildSession` runs `driveBotTurns()` at construction, so with bots ahead of you in the order
* the game has already moved before anybody can connect. Those steps must be DROPPED, not
* queued: a connecting client's `publicReset` is the board as it stands after those very moves,
* so replaying them onto it would draw positions the game had already left.
*
* Found by review 2026-09-09 rather than by a failing test, which is why this one exists.
*/
const session = createSession(1917398, config, ['Alice', 'Bob', 'Carol'], [1, 2]);
const push = session.connect(0 as PlayerIndex);
assert.ok(push.publicReset, 'a connecting client needs a baseline');
assert.equal(push.steps, undefined, 'the connect push must carry no steps at all');
// And the first real broadcast must carry only what THIS move produced — nothing older.
const option = push.menu?.options[0];
assert.ok(option, 'seat 0 should have something to do');
const r = session.intent(0 as PlayerIndex, 1, option);
assert.ok(r.accepted);
const steps = [...r.pushes.values()][0]?.steps ?? [];
assert.ok(steps.length > 0, 'the move produced no steps');
/**
* The first step delivered must be THIS seat's move — not a bot's, which is what a replayed
* opening turn would look like. The sequence does NOT restart at 0: `takeSteps` empties the
* collector without rewinding the counter, so the first thing a client sees may be seq 14. That
* is fine and deliberate — what 0.8.1's gap detection needs is monotonic and dense, not
* zero-based.
*/
assert.equal(steps[0]!.player, 0, 'the first delivered step was not the move just made');
assert.equal(steps[0]!.cause, option.type);
steps.forEach((st, i) => {
if (i > 0) assert.equal(st.seq, steps[i - 1]!.seq + 1, 'sequence must stay dense');
});
});
it('every seat gets the same public steps, and a connect gets a baseline to merge onto', () => {
const session = createSession(1917398, config, ['Alice', 'Bob', 'Carol'], [1, 2]);
const connected = session.connect(0 as PlayerIndex);
assert.ok(connected.publicReset, 'a connecting client needs a baseline for its step queue');
let seen = 0;
for (let i = 0; i < 60; i++) {
const menu = session.connect(0 as PlayerIndex).menu;
const option = menu?.options[0];
if (!option) break;
const r = session.intent(0 as PlayerIndex, i, option);
if (!r.accepted) break;
const pushes = [...r.pushes.values()];
if (pushes.length === 0) continue;
const first = pushes[0]!.steps ?? [];
if (first.length === 0) continue;
seen += first.length;
for (const p of pushes) {
assert.deepEqual(p.steps, first, 'every seat must receive the identical public steps');
}
}
assert.ok(seen > 0, 'no steps reached a push, so this proved nothing');
});
});