v0.7.9.5 — two answers to one question, and the copy nobody read

Both faults are in what 0.7.9.4 had just built, and both are the same
shape: a second copy of an answer that agreed with the first until it
didn't.

#96 — the §3.3 vote has no actor, and the screen named one anyway. The
vote is PARALLEL: every un-voted seat may vote at any moment, in any
order, one refusal ends it, and `apply.ts` says where it accepts one that
there is no actor to be. The turn chart named the last seat to move
before the timetable ran out — no more claim on the vote than anybody
else — directly above a tally correctly showing three seats outstanding.

The cause is worth more than the symptom. `currentActor(game)`
(`web/game.ts`) guarded on `status !== 'active'`; `currentActorOfState`
(`sim/view.ts`), added the same day in #95 and the one the frame calls,
did not, so it handed back whatever `clock.currentActor` was left
holding. The view now carries the guard and `currentActor` delegates to
it. That matters more than the tidiness: `currentActor` is what REFUSES
an intent, so a screen answering differently tells the table to wait on a
player the server would turn away.

The fourth of this class after Gitea#21, #22 and #94 — but the first
found by asking a view helper its question in a state the game is not
`active` in, which is the generalisation and is cheaper than finding the
fifth the same way.

#97 — narration reaches a seat once, by one path. `Frame.lines` carried
the whole log on every push to every seat, and nothing read it:
`RemoteSession` accumulates from `push.lines` alone and its `lines()`
returns that accumulator, so the log was serialised into every frame,
grew all game, and was discarded on arrival while `linesSince` sent the
same text correctly beside it.

The duplicate was masking a bug rather than merely wasting bandwidth.
`connect()` cleared `lastFrame` but not `sentLines`, so a reconnecting
seat was told "nothing new since your last push" while the browser it
answered had just reloaded from an EMPTY accumulator — the history panel
came back blank, mid-game, with the server holding the whole log. So the
two halves are one change, and the plan's instruction taken alone ("stop
passing the full game log into `frameFor()`") would have deleted a real
behaviour rather than a duplicate.

Every remaining reader of `Frame.lines` was checked before the field was
emptied: all of them are the solitaire and replay path, which builds
Frames through `snapshot()` directly and never goes near a session.

One test was wrong before the code was. The first draft of the reconnect
test connected inside its own fixture, so both sides of the comparison
were the empty array and it passed against the broken server. Each test
now asserts its premise is non-empty before comparing.

Also: `docs/plans/jitsi-common-board.md` is committed. It was never added
— not ignored, just missed — while TODO.md cites it twice as the plan for
all of v0.8.0 and the last two releases were built from it, so a clone
got a TODO pointing at a file that did not exist.

917 tests pass, up from 909.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5boPxP6JHRYMm8adXaF5R
This commit is contained in:
Jesse.Markowitz
2026-09-07 20:35:19 -04:00
co-authored by Claude Opus 5
parent ebd16983e2
commit d5445badcc
9 changed files with 1215 additions and 15 deletions
+68
View File
@@ -19,6 +19,74 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
---
## 0.7.9.5 — 2026-09-07
Two faults in what 0.7.9.4 had just built, both of the same shape: a second copy of an answer that
agreed with the first until it didn't.
### The §3.3 vote had no actor, and the screen named one anyway (#96)
Extended play's vote is **parallel**. Every un-voted seat may vote at any moment, in any order, and
one refusal ends it — `apply.ts` says in as many words where it accepts a vote that there is no actor
to be. So the honest answer to "whose turn is it" is nobody, and the honest answer to "who are we
waiting on" is every un-voted seat, which is exactly what the tally beside the turn chart already
drew.
The chart disagreed with the tally directly above it. It named the last seat to move before the
timetable ran out — a seat with no more claim on the vote than anybody else — while the tally
correctly showed three outstanding.
The cause is worth more than the symptom. `currentActor(game)` in `web/game.ts` guarded on
`status !== 'active'` and returned null. `currentActorOfState(state)` in `sim/view.ts` — added the
same day in #95, and the function the frame actually calls — had no such guard, so it handed back
whatever `clock.currentActor` was left holding after the game stopped being `active`. Two functions
answering one question, correct in every state anybody had looked at.
`currentActorOfState` carries the status guard now and `currentActor` delegates to it, so there is
one answer. That matters more than the tidiness: **`currentActor` is what refuses an intent**, so a
screen answering differently is telling the table to wait on a player the server would turn away.
This is the fourth of this class in a row after Gitea#21, #22 and #94. It is the first found by
asking a view helper its question in a state the game is **not** `active` in — which is the
generalisation, and cheaper than finding the fifth the same way.
### Narration reaches a seat once, by one path (#97)
`Frame.lines` carried the whole narration log on every push, to every seat, and nothing read it.
`RemoteSession` (`web/session.ts`) accumulates `lines` from `push.lines` alone, and its `lines()`
returns that accumulator — so the log was serialised into every frame, grew all game, and was
discarded on arrival, while `linesSince` sent the same text correctly beside it.
**The duplicate was masking a bug rather than merely wasting bandwidth.** `connect()` cleared
`lastFrame` but not `sentLines`, so a reconnecting seat was told "nothing new since your last push"
— while the browser it was answering had just reloaded and started from an empty accumulator. The
history panel came back blank, mid-game, with the server holding the whole log and shipping it in the
one field nobody reads.
So the two halves are one change, and the plan's instruction taken alone — "stop passing the full
game log into `frameFor()`" — would have deleted a real behaviour instead of a duplicate. A
(re)connect now resets the seat's watermark, and `Push.lines` on a connect **is** the history, which
is what lets the Frame stop carrying a second copy.
Every remaining reader of `Frame.lines` was checked before the field was emptied: all of them
(`sim/replay.ts`, `web/replays.ts`, and the sim and replay tests) are the solitaire and replay path,
which builds its Frames through `snapshot()` directly and never goes near a session.
**One test was wrong before the code was.** The first draft of the reconnect test connected inside
its own fixture, so both sides of the comparison were the empty array and it passed against the
broken server — two empty arrays are `deepEqual`. Each of these tests now asserts its premise is
non-empty before comparing.
### Also
`docs/plans/jitsi-common-board.md` is committed. It was never added — not ignored, just missed —
while `TODO.md` cites it twice as the plan for all of v0.8.0 and the last two releases were built
from it, so a clone got a TODO pointing at a file that did not exist.
917 tests pass, up from 909.
---
## 0.7.9.4 — 2026-09-07
Gitea#20 step 1, done as its own release rather than as the first hour of 0.8.0 — and a Red Flag you
+38
View File
@@ -1792,6 +1792,44 @@ the numbers stay so cross-references above and below still resolve.
consulted when somebody is genuinely acting. `currentActorOfState` exists anyway, as one place
for the next reader to ask.
96. ~~**The screen named a player nobody was waiting on, all through the §3.3 vote.**~~ — done
2026-09-07 in v0.7.9.5. Extended play's vote is PARALLEL — open to every un-voted seat at once,
in any order, one refusal ending it — and `apply.ts` says where it accepts one that there is no
actor to be. The turn chart named a seat anyway: the last to move before the timetable ran out,
who has no more claim on the vote than anybody else, drawn beside a tally that correctly showed
three seats outstanding.
**The cause was two functions that agreed until they didn't.** `currentActor(game)`
(`web/game.ts`) guarded on `status !== 'active'` and returned null; `currentActorOfState(state)`
(`sim/view.ts`), added the same day in #95, had no such guard and handed back whatever
`clock.currentActor` was left holding. The frame took the second. `currentActorOfState` carries
the guard now and `currentActor` delegates to it, so there is one answer rather than two.
**Worth knowing:** the disagreement is the bug, not either answer on its own. `currentActor` is
what REFUSES an intent, so a screen answering differently tells the table to wait on a player the
server would turn away. And this is the fourth of the same class in a row after Gitea#21, #22 and
#94 — but the first found by asking the question of a state the game is not `active` in, which is
the generalisation worth keeping: a view helper needs exercising outside the happy phase.
97. ~~**Narration was sent twice by a path nobody read, and the duplicate hid a blank history
panel.**~~ — done 2026-09-07 in v0.7.9.5, the second half of Gitea#20 step 1. `Frame.lines`
carried the WHOLE log on every push to every seat, grew all game, and was thrown away on arrival:
`RemoteSession` (`web/session.ts`) accumulates `lines` from `push.lines` alone and its `lines()`
returns that accumulator. `linesSince` was sending the same text, correctly, beside it.
**The waste was masking a real fault.** `connect()` cleared `lastFrame` but not `sentLines`, so a
reconnecting seat was told "nothing new since your last push" — while the browser it was
answering had just reloaded and started from an EMPTY accumulator. The history panel came back
blank mid-game, with the server holding the whole log and shipping it in the one field nobody
reads.
**So the two halves are one change**, and doing only the half the plan asks for — "stop passing
the full game log into `frameFor()`" — would have deleted a real behaviour rather than a
duplicate. A (re)connect resets the seat's watermark, and `Push.lines` on a connect IS the
history, which is what lets the Frame stop carrying a second copy. Every remaining reader of
`Frame.lines` is the solitaire and replay path, which builds its Frames through `snapshot()`
directly and is untouched.
32. ~~**Tell the 0.4.9 playtesters their saves are dead, before they find out.**~~ — done
2026-09-07. `PLAYTEST-0.7.4.md` was written for exactly this and did its job; Jesse, 2026-09-07:
"a temporary document to help some of the playtesters out on making the big jump, but that is no
+917
View File
@@ -0,0 +1,917 @@
# Station Master Jitsi Common Board Implementation Plan
**Status:** Final planning document. No implementation has been performed.
## 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.
The publisher represents the game table, not a player or bot. Human and bot actions both update the same public board. No hands, objectives, legal moves, random seed, private draws, or other player-only state may enter the display data path.
The implementation is divided into independently useful stages:
1. Establish a secure public-state projection and close existing narration leaks.
2. Add a public display stream, credentials, persistence, and reconnect behavior.
3. Build the reusable 1280×720 common-board renderer.
4. Preserve individual human and bot actions as display animation steps.
5. Add a minimal visual-only Jitsi engine.
6. Supervise one headless Chromium process per published game.
7. Integrate lifecycle, configuration, packaging, health, and live verification.
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
This plan is based on the current Station Master server, engine, multiplayer view, persistence, and untracked Jitsi harness, plus a read-only review of the sibling `jitsi-transcription` packaging repository at commit `9cae1877a9d9a9f489de8f870dad6ff011521c9d`.
The Jitsi Transcription review materially changes the earlier architecture:
- Use direct `child_process.spawn` Chromium supervision, not Playwright or CDP.
- Use one Chromium process and temporary browser profile per published game, not multiple contexts in one shared browser.
- Copy the minimal proven Jitsi/control patterns into Station Master; do not create a runtime dependency on the sibling repository.
- Treat `conference.authenticationRequired` as a retryable “waiting for moderator” state.
- Use the exact StartOS-proven Chromium flag set.
- Explicitly disable third-party requests in `JitsiMeetJS.init`.
- Gracefully leave Jitsi before terminating Chromium to reduce ghost participants.
- Package Chromium and `tini`; visual-only publishing needs neither PulseAudio nor Xvfb.
- Default to one concurrent publisher until target-hardware measurements justify more.
The sibling repository had unrelated local modifications and untracked files. They are not part of this plan.
## Public interfaces and types
### Public game projection
Introduce dedicated allow-listed types. Do not derive them with `Omit<Frame, ...>` because new private `Frame` fields could then leak automatically.
```ts
type PublicPlayerView = {
index: PlayerIndex;
seat: PlayerIndex;
name: string;
revenue: number;
handCount: number;
redFlagHeld: boolean;
};
type PublicDistrictView = {
seat: PlayerIndex;
player: PlayerIndex;
cells: readonly CellView[];
facilities: readonly FacilityView[];
runningRow: number;
limits: unknown;
};
type PublicFrame = {
protocolVersion: 1;
status: GameState['status'];
clock: PublicClockView;
config: PublicConfigView;
scoring: PublicScoringView;
players: readonly PublicPlayerView[];
division: readonly DivisionView[];
districts: readonly PublicDistrictView[];
deckCounts: PublicDeckCounts;
departments: PublicDepartmentsView;
salvage: PublicSalvageView;
yards: PublicYardsView;
timetable: PublicTimetableView;
};
```
Reuse existing view types only after verifying every reused field is public. Create narrower public variants where an existing type includes viewer-specific or private data.
The projection must never include:
- `viewer`
- card identities in any player’s hand
- `justDrawn`
- objectives
- decisions or private prompts
- menus, actions, legal moves, or blocked reasons
- seed or RNG state
- raw `GameState`
- intent history
- private event details
- unresolved internal ordering data
- full shared narration logs
### Display stream
```ts
type PublicFrameDelta = PartialPublicFrameDelta;
type DisplayReset = {
type: 'display.reset';
seq: number;
frame: PublicFrame;
};
type DisplayStep = {
type: 'display.step';
seq: number;
actor: {
player: PlayerIndex;
seat: PlayerIndex;
source: 'human' | 'bot';
};
frame: PublicFrameDelta;
lines: readonly PublicNarrationLine[];
};
type DisplayMessage = DisplayReset | DisplayStep;
```
A new or reconnected display client always receives `display.reset`. Subsequent accepted intents produce ordered `display.step` messages. The server does not replay missed steps; reconnecting clients reset to the latest public state.
### Jitsi publisher state
```ts
type JitsiPublisherState =
| 'disabled'
| 'queued'
| 'launching'
| 'connecting'
| 'waiting-for-admission'
| 'waiting-for-moderator'
| 'publishing'
| 'reconnecting'
| 'stopping'
| 'failed';
```
Expose sanitized state, last transition time, and a safe error summary through the existing session/health surfaces. Never expose meeting credentials, display credentials, Jitsi tokens, XMPP service credentials, or browser launch URLs.
## Step 1 — Secure public-state projection
### Current code findings
`src/sim/view.ts` currently builds `Frame` for one real viewer and defaults that viewer to player zero. It combines shared table state, one district, and viewer-private fields. Calling it for a spectator would silently expose player zero’s district and private information.
`src/server/session.ts` calls:
```ts
snapshot(game.state, game.log, ..., seat)
```
This embeds the complete shared narration log in every `Frame`, while `Push.lines` also sends narration incrementally.
Existing narration has at least two privacy leaks:
- `newMultiplayerGame()` places the seed in the shared log.
- A blind Home Office draw can resolve the drawn card to its real name in shared narration.
Existing redaction tests pass `[]` for narration and mainly search internal IDs, so they do not detect resolved card names or seed text in the full serialized payload.
`currentActor(game)` accounts for Superintendent/pending decisions, while `snapshot()` reads `state.clock.currentActor`. A public display using the latter could highlight the wrong district.
Employee Rotation means district ownership cannot be assumed to match player index. Districts must be keyed by seat, with current ownership resolved through `playerAtSeat`.
### Required changes
Create explicit projection helpers in the simulation/view layer:
- `projectDistrict(state, seat)`
- `projectDivision(state)`
- `projectSharedTable(state)`
- `publicSnapshot(state)`
- `currentActorOfState(state)`
Refactor the player snapshot and public snapshot to share only safe lower-level projection helpers. Do not implement the public view by repeatedly calling the player `snapshot()` function.
Make the existing actor logic use the same engine-level helper so player views, bot execution, and the common board agree during decisions and Superintendent actions.
Add the Red Flag holder to the public player projection. It is public game state but is currently absent from `Frame`.
Remove seed narration from multiplayer game creation. Retain the seed only in persistence and administrative/replay data.
Change blind-draw narration to a generic public line such as “Home Office drew a card.” Keep the card identity available only to the drawing player through the existing owner-only `justDrawn` mechanism.
Stop passing the full game log into `frameFor()`. Continue sending sanitized incremental narration through `Push.lines`.
### Tests
Add tests that serialize the entire public frame and player pushes, then search for:
- every opponent hand card ID
- every opponent hand card display name
- current objective IDs and names
- `justDrawn` for the wrong player
- seed values and seed narration
- private decision/menu/action data
Cover:
- a newly created multiplayer game
- a blind Home Office draw
- a pending decision
- Superintendent acting
- Employee Rotation before and after ownership changes
- reconnect pushes
- a finished game
Acceptance requires an allow-list review of every `PublicFrame` property. Passing redaction tests alone is insufficient.
## Step 2 — Display credentials, stream, and persistence
### Display metadata
Create separate per-game display metadata rather than extending the authoritative game save:
```ts
type SavedDisplayMetadata = {
schemaVersion: 1;
room: string;
viewToken: string;
publisherToken: string;
nextSequence: number;
createdAt: number;
};
```
Store it in the same per-game data directory as `display.json`, using the persistence layer’s existing atomic-write pattern.
Generate:
- a cryptographically random Jitsi room name
- a high-entropy `viewToken`
- a separate high-entropy `publisherToken`
Do not derive any token from game ID, room name, player token, seed, or timestamp.
For older saves without `display.json`, generate the metadata once on resume and persist it. Failure to initialize display metadata must disable display/Jitsi for that game without preventing the underlying game from resuming.
### HTTP endpoints
Add:
- `GET /display.html#token=<viewToken>` — manual common-board page
- `GET /api/display/stream?token=<viewToken>` — public-board SSE
- `GET /display-agent.html` — internal headless publisher page
- WebSocket upgrade at `/api/display/control` — supervisor/agent control
Extend the authenticated game/session response with:
- display URL
- Jitsi meeting URL
- sanitized publisher state
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.
### SSE behavior
Maintain a display subscriber set per game.
On connection:
1. Authenticate `viewToken`.
2. Produce the latest `PublicFrame`.
3. Send `display.reset` with the current sequence.
4. Continue existing heartbeat behavior.
5. Send later `display.step` messages in sequence order.
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.
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
- Compare tokens without placing them in error messages.
- Do not log query strings or control registration messages containing tokens.
- Never accept game intents through display endpoints.
- Do not let a view token register as a publisher.
- Apply payload and connection limits independently from player SSE.
- Keep display failure isolated from player pushes and game persistence.
### Tests
Add HTTP tests after refactoring `startServer()` to return the server/listening handle needed by tests.
Cover:
- valid and invalid view tokens
- publisher token rejected as a view token
- reset on first connect and reconnect
- monotonically increasing sequence IDs
- public deltas reconstruct the same frame as a fresh reset
- heartbeat behavior
- no private fields in raw SSE bytes
- legacy save migration
- atomic `display.json` persistence
- display failure not interrupting `/api/intent` or player SSE
## Step 3 — Reusable common-board renderer
### Renderer structure
Build one renderer shared by:
- the manual browser display
- the headless Jitsi publisher page
Use a fixed 1280×720 canvas at 10 frames per second. Set the captured video track’s `contentHint` to `"detail"`.
Keep the canvas palette stable. The Jitsi Transcription prototype deliberately changed color themes to prove updates were arriving; that behavior strobes during frequent game actions and must not enter the product.
Separate the renderer into:
- a pure layout/view-model stage
- image/SVG preparation
- canvas drawing
- animation queue management
### Layout
Use this fixed layout:
- Header: game name/status, day/stage/phase, current actor, Red Flag.
- Main left: persistent Division board.
- Main right: focused district.
- Footer/side panels: player standings, hand counts, timetable, public decks/resources, and recent narration.
- Compact district summaries: every district’s owner, revenue, and operational status.
The focused district is:
1. the acting player’s current seat;
2. otherwise the most recent actor’s seat;
3. otherwise seat zero.
Resolve owner from seat on every frame so Employee Rotation updates the labels without moving the district itself.
The complete `PublicFrame` carries all public districts even though the 720p layout focuses one at a time.
### Existing assets
Reuse the existing Division and office SVG generators and exported board CSS. Adapt their APIs so the Division roster can be rendered without a private viewer.
Render SVG output into canvas-safe images. Cache images by serialized SVG/content key and invalidate only when the corresponding public model changes.
Do not recreate game rules or labels inside the renderer. The projection layer supplies display-ready public labels.
### Display behavior
- Human and bot actions use the same animation path.
- Normal display-step duration: 700 ms.
- If the queue exceeds 12 steps, use 200 ms catch-up transitions.
- A reset clears queued animations and renders immediately.
- Rendering or asset failure keeps the previous good frame and reports a sanitized diagnostic.
- Recent narration is bounded and uses only sanitized `DisplayStep.lines`.
- No server-side sleeps are permitted.
Update `scripts/build-web.ts` to explicitly build/copy the new display entry points, HTML pages, styles, and Jitsi vendor asset. The current static build manually lists its artifacts, so relying on automatic discovery will omit the pages.
### Tests
Test the pure layout model for:
- focused-district selection
- Employee Rotation ownership
- Superintendent decisions
- two through four players
- empty and dense boards
- long player/facility names
- finished-game state
Test renderer lifecycle with a fake canvas/image layer:
- reset clears the queue
- step order is preserved
- catch-up speed activates at the threshold
- stopped renderers stop timers and media tracks
- no private input type is accepted
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
### Current code findings
`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.
Engine events cannot be replayed into display state. One accepted intent may run automatic `drain()` work and emit several events, and the event list is not a complete reducer.
### Required changes
Introduce a display-step collector inside `GameSession`.
After every successful `submit()`:
1. Resolve the acting player and current seat.
2. Produce the sanitized narration lines added by that submission.
3. Create the next `PublicFrame` immediately.
4. Delta it against the last emitted public frame.
5. Assign the next display sequence.
6. Emit one `DisplayStep`.
Apply this both to:
- the human submission in `intent()`
- every successful bot submission inside `driveBots()`
Capture the frame immediately. Do not retain a mutable `GameState` reference for later projection because every retained reference would otherwise resolve to the final state.
One display step corresponds to one accepted intent, including automatic consequences drained by that intent. Do not create one step per low-level `GameEvent`.
Keep player pushes unchanged: players still receive the final coalesced result after all immediately due bots finish.
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
Display projection or broadcasting must not invalidate an already accepted game move.
If step generation fails:
- record a sanitized server diagnostic
- mark the publisher/display state degraded
- send a fresh reset on the next successful display update
- continue normal game and player push processing
### Tests
Cover:
- one human intent with no bot response
- one human intent followed by several bot intents
- consecutive bot turns
- bot pending decisions
- automatic engine work within one intent
- ordering of narration and frames
- sequence continuity
- player pushes remaining coalesced
- reconstructed display state matching `publicSnapshot()` after the final step
## Step 5 — Minimal visual-only Jitsi engine
### 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.
Retain only:
- Jitsi configuration parsing
- connection/conference lifecycle
- lobby handling
- reconnect classification/backoff
- generated-display track creation
- generated-display cleanup and republishing
- normalized state/error events
- minimal control protocol
Pin the known working `lib-jitsi-meet` release:
```text
v2192.0.0+d6f3312f
```
Add `ws` for the Node control broker. Do not add Playwright.
### Agent page
`display-agent.html` must:
1. Validate its opaque session ID and publisher token from the URL fragment.
2. Connect to `/api/display/control`.
3. Register as the engine for exactly one session.
4. Wait for a supervisor command before joining Jitsi.
5. Connect to the public display SSE using the supplied view token.
6. Render the same common-board canvas as the manual page.
7. Capture the canvas stream at 10 fps.
8. Join Jitsi and publish it as a desktop video track.
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.
### Jitsi publishing
Use:
```ts
canvas.captureStream(10)
JitsiMeetJS.createLocalTracksFromMediaStreams([{
mediaType: 'video',
sourceType: 'generated',
stream,
track: stream.getVideoTracks()[0],
videoType: 'desktop'
}])
```
Require exactly one generated video track. Dispose any unexpected auxiliary tracks.
Wait for the first video frame before publishing and report a diagnostic if it does not arrive. Do not automatically recycle a healthy track merely because a remote participant initially sees a blank publication; the existing harness observed occasional first-publication blankness on the Jitsi side.
On reconnect:
- retain the program-owned canvas stream
- remove/dispose the stale Jitsi local track
- reconnect and rejoin
- create a fresh Jitsi wrapper track around a clone of the retained stream
- republish it
### Jitsi initialization and privacy
Initialize with:
```ts
JitsiMeetJS.init({
disableAudioLevels: true,
enableAnalyticsLogging: false,
disableThirdPartyRequests: true
});
```
The pinned library declarations confirm `disableThirdPartyRequests` is supported.
Do not initialize microphones, cameras, remote audio sinks, chat, TTS, STT, analytics, or rtcstats endpoints.
Before release, capture browser network destinations during a live session and verify that traffic is limited to:
- Station Master loopback/server endpoints
- the configured Jitsi deployment and its advertised media infrastructure
Unexpected telemetry destinations fail acceptance.
### Meeting states
Handle these explicitly:
- `waiting-for-admission`: the participant joined a lobby and is waiting for a moderator.
- `waiting-for-moderator`: the guest cannot create the room because no authenticated moderator has opened it.
- `reconnecting`: a previously joined publisher lost its connection and is retrying.
- `failed`: malformed configuration, exhausted retry budget, unrecoverable Jitsi error, or repeated browser failure.
`conference.authenticationRequired` may arrive asynchronously as a conference error after the initial join command appears successful. Handle both synchronous and event paths.
For waiting-for-moderator:
- leave/disconnect the failed attempt
- retry every 8 seconds
- stop after `JITSI_WAIT_FOR_MODERATOR_SECONDS`, default 600
- immediately continue when a human moderator opens the meeting
For lobby admission, remain connected until admitted, stopped, or the configured join deadline expires.
### Tests
Port/adapt the sibling repository’s proven tests for:
- generated-display track contract
- lifecycle transitions
- lobby state
- asynchronous `authenticationRequired`
- reconnect classification
- generated-display republish
- cleanup after publish failure
- stale callbacks from an old connection
- initialization privacy flags
- no audio/camera track creation
## Step 6 — Chromium publisher supervisor
### Process model
Create one Chromium child process per published game.
Do not share one Chromium process across games. Per-game processes provide crash isolation, simple lifecycle ownership, and match the production-proven Jitsi Transcription design.
Default publisher capacity to one:
```text
JITSI_MAX_PUBLISHERS=1
```
Games beyond capacity enter `queued` in creation order. Make the cap configurable only after measuring CPU and memory on target Station Master hardware.
### Browser launch
Find Chromium using:
1. `CHROME_BIN`
2. `/usr/bin/chromium`
3. `/usr/bin/chromium-browser`
4. `/usr/bin/google-chrome`
Use a unique temporary user-data directory per publisher and remove it after exit.
Launch with the StartOS-proven flags:
```text
--headless=new
--no-sandbox
--disable-dev-shm-usage
--autoplay-policy=no-user-gesture-required
--use-fake-device-for-media-stream
--use-fake-ui-for-media-stream
--disable-background-timer-throttling
--disable-renderer-backgrounding
--disable-backgrounding-occluded-windows
```
`--no-sandbox` is acceptable only inside the existing StartOS container boundary and because Chromium loads the Station Master-owned loopback agent page.
Filter noisy Chromium stderr, but retain messages matching fatal conditions, renderer crashes, out-of-memory errors, and “Aw, Snap”.
### Control protocol
Use a versioned, session-keyed JSON protocol over `/api/display/control`.
Registration includes:
- protocol version
- role `engine`
- session ID
- publisher token
The broker must:
- validate every message
- limit payloads to 64 KiB
- disable per-message compression
- route commands/events only to the registered session
- 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
Public frames remain on SSE and never traverse this control protocol.
### Lifecycle and recovery
Supervisor flow:
1. Allocate capacity.
2. Spawn Chromium.
3. Wait for authenticated engine registration.
4. Send Jitsi join configuration.
5. Wait for joined/admitted state.
6. Command generated-display publication.
7. Monitor process, control socket, and Jitsi state.
8. Gracefully stop on game completion, server shutdown, or administrative stop.
For unexpected Chromium exit during an active game:
- mark `reconnecting`
- clean the old profile
- relaunch with delays of 1, 2, 4, 8, and 16 seconds
- reset the consecutive-failure count after five stable minutes
- enter `failed` after five consecutive launch/registration failures
- keep the underlying game and browser display operational
### Graceful teardown
On stop:
1. Send the engine a leave command.
2. Wait up to five seconds for confirmation.
3. Send Chromium `SIGTERM`.
4. Wait up to three additional seconds.
5. Use `SIGKILL` only if it remains alive.
6. Remove the temporary profile.
7. Release publisher capacity.
Clean leave matters because Jitsi may show a stale participant for 30–120 seconds after abrupt termination. The board does not consume remote audio, so these ghosts are cosmetic, but duplicate meeting participants remain undesirable.
Publish the final game state for a five-minute completion grace period, then leave the meeting. Server shutdown bypasses this grace period and leaves immediately.
### Tests
Use fake child processes and fake control sockets to test:
- exact required Chromium flags
- binary discovery
- profile isolation and cleanup
- capacity queue ordering
- engine registration authentication
- per-session supersession
- command timeout and disconnection
- crash relaunch backoff
- retry exhaustion
- graceful leave before termination
- forced kill fallback
- publisher failure not affecting the game session
## Step 7 — Configuration, lifecycle, packaging, and observability
### Configuration
Support:
```text
JITSI_SERVER_URL
JITSI_GUEST_DOMAIN
JITSI_SERVICE_URL
JITSI_XMPP_DOMAIN
JITSI_MUC_DOMAIN
JITSI_FORCE_JVB=false
JITSI_MAX_PUBLISHERS=1
JITSI_WAIT_FOR_MODERATOR_SECONDS=600
JITSI_JOIN_TIMEOUT_SECONDS=600
JITSI_FINISHED_GRACE_SECONDS=300
CHROME_BIN
```
Require `JITSI_SERVER_URL` to be an HTTP(S) origin without credentials, path, query, or fragment.
Default the XMPP WebSocket to:
```text
wss://<jitsi-host>/xmpp-websocket
```
Default MUC to `conference.<xmpp-domain>`. Keep explicit overrides for self-hosted deployments.
If `JITSI_SERVER_URL` is absent:
- browser common-board display remains enabled
- Jitsi meeting URL and publisher are disabled
- game creation and play remain unaffected
JWT/authenticated publisher support is out of scope for the first version. Authenticated-room deployments rely on a human moderator opening the room and the publisher’s waiting-for-moderator retry.
### Session integration
Create display metadata when the lobby starts a game.
Return the meeting and display links to every authenticated player. All players receive the same links.
On server startup:
- load active game saves
- load or migrate display metadata
- rebuild current public snapshots
- queue publishers only for active games
- do not automatically republish completed games
On SIGTERM, including StartOS backup shutdown:
- stop accepting new publisher work
- command every engine to leave
- terminate browsers
- then close HTTP/control services
### Health and logs
Health must distinguish:
- game server availability
- common display availability
- Jitsi configuration present
- Chromium available
- publishers active/queued/failed
Do not report Jitsi publishing as healthy merely because the supervisor is running.
Use structured logs containing:
- game/session ID
- publisher state transition
- safe Jitsi room identifier
- browser exit code/signal
- retry attempt
- sanitized error category
Exclude all tokens, full control frames, player private state, query strings, and browser profile paths.
### Dependencies and packaging
Add runtime dependencies:
- the pinned `lib-jitsi-meet` release
- `ws`
Add Chromium and `tini` to the Station Master runtime image or companion StartOS packaging repository.
Use `tini` as PID 1 so terminated Chromium children are reaped.
Visual-only Station Master publishing does not require:
- PulseAudio
- Xvfb
- xauth
- ffmpeg
- Playwright browsers
- CDP tooling
Run the service as a non-root application user. Chromium still receives `--no-sandbox` because the StartOS subcontainer does not grant the kernel capabilities required by Chromium’s internal sandbox.
Do not raise the package RAM requirement based solely on the Jitsi Transcription measurements. Measure the combined Station Master server plus publisher on actual target hardware first.
## End-to-end test plan
### Automated tests
Run:
- Station Master typecheck
- all existing engine/server/web tests
- public-projection and leak tests
- display delta/reconstruction tests
- HTTP/SSE authentication tests
- renderer lifecycle tests
- control-protocol tests
- Jitsi adapter tests with a fake runtime
- Chromium supervisor tests
- persistence migration tests
### Local integration
With a local or test Jitsi deployment:
1. Start a two-player game with one bot.
2. Open the manual display and both player screens.
3. Confirm human moves update all three.
4. Confirm bot moves appear as distinct ordered animations.
5. Join the Jitsi meeting from another browser.
6. Confirm the Station Master board is published as a desktop share.
7. Force an XMPP/media disconnect.
8. Confirm reconnect and display-track republish.
9. Stop the game/server and confirm the publisher leaves.
### Authenticated deployment
On a Jitsi deployment where guests cannot create rooms:
1. Start the Station Master game before opening the meeting.
2. Confirm publisher state becomes `waiting-for-moderator`.
3. Open the meeting as a moderator.
4. Confirm the publisher joins on the next retry.
5. If lobby is enabled, confirm `waiting-for-admission` until admitted.
6. Confirm neither state is reported as an immediate terminal failure.
### Privacy verification
Capture:
- raw display SSE
- rendered canvas screenshots
- control WebSocket messages
- Jitsi network destinations
- server logs
Verify that none contains:
- card identities from hands
- objectives
- player-only prompts or moves
- private draw identities
- seed
- view/publisher tokens in logs
- unintended third-party telemetry
### Soak and target hardware
Run at least a two-hour Station Master integration soak with:
- continual public-board updates
- human and bot moves
- two forced connection drops
- one forced Chromium termination
- clean service shutdown
Record:
- container RSS
- Chromium RSS
- CPU during idle and animation bursts
- Jitsi reconnect time
- display queue depth
- dropped SSE clients
- stale meeting-participant duration after graceful and forced exits
The sibling repository’s four-hour run validates the underlying headless Jitsi approach, but Station Master still needs this integration-specific measurement.
## Acceptance criteria
The feature is complete when:
- Every player can open one shared browser display.
- The same display is visible as a desktop share in the game’s Jitsi meeting.
- Human and bot moves update that board through the same path.
- Consecutive bot intents remain visibly distinct.
- A reconnecting display reconstructs the latest state without replay.
- Jitsi disconnection republishes the existing canvas stream.
- A guest publisher waits for a moderator rather than failing immediately.
- Browser/Jitsi failure never prevents normal game play.
- Shutdown leaves the conference before Chromium termination.
- No private game information appears in projection, transport, rendering, logs, or Jitsi.
- No unexpected third-party telemetry connection is observed.
- Chromium resource use is measured on target hardware before increasing concurrency.
## Assumptions and defaults
- The common board is a separate participant named `Station Master — Common Board`.
- It represents the table, never a particular human or bot.
- Jitsi is configurable and self-hosted; meet.jit.si-specific behavior is not assumed.
- One publisher process is allowed by default.
- A human moderator may need to open or admit the publisher.
- Public display and normal multiplayer remain valuable and operational without Jitsi.
- The sibling Jitsi Transcription repository is a reference implementation, not a shared runtime package.
- The untracked Station Master harness remains research material and is not promoted wholesale into production.
- Audio, transcription, speech, chat, camera capture, player screen sharing, and JWT publisher authentication are out of scope.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "station-master",
"version": "0.7.9.4",
"version": "0.7.9.5",
"private": true,
"type": "module",
"description": "Station Master — a railroad operations game",
+15 -1
View File
@@ -174,8 +174,17 @@ function buildSession(
};
let open: OpenSpan | null = openSpanFor(Date.now());
/**
* A seat's full Frame, with `lines` deliberately EMPTY (#97).
*
* Narration reaches a client by ONE path — `Push.lines` — because `RemoteSession`
* (`web/session.ts`) accumulates from that field alone and its `lines()` returns the accumulator.
* Passing `game.log` here serialised the entire log into every frame for every seat, where it grew
* all game and was thrown away on arrival, while `linesSince` correctly sent the same text beside
* it. The duplicate was not merely waste: it masked the reconnect bug `connect` fixes below.
*/
function frameFor(seat: PlayerIndex): Frame {
return snapshot(game.state, game.log, null, null, null, false, seat);
return snapshot(game.state, [], null, null, null, false, seat);
}
function linesSince(seat: PlayerIndex): { text: string; tone: string }[] {
@@ -341,6 +350,11 @@ function buildSession(
// A (re)connect always starts from a clean slate — no cache to trust across a lost connection
// (or a server restart, Phase 3) — so the honest thing is a full Frame, not a delta.
lastFrame.delete(seat);
// ...and the NARRATION watermark with it (#97). The browser this answers has just reloaded
// from an empty accumulator, so a seat told "nothing new since your last push" came back to a
// 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);
},
+20 -8
View File
@@ -1407,17 +1407,29 @@ export function projectDivision(s: GameState): DivisionView[] {
/**
* WHO THE GAME IS WAITING ON — the one answer, asked one way (Gitea#20 step 1).
*
* `clock.currentActor` is not it. The engine sets that null while an interruption is standing —
* a §8.1 clearance goes to the Superintendent, a Yard Office offer to the district's owner — and a
* view reading the raw field therefore reports "nobody" during exactly the moments a player is being
* waited on. `actingPlayer` knows the rule and is the engine's own answer.
* TWO THINGS HAVE TO BE TRUE AT ONCE, and each was somewhere else before #96 put them together.
*
* Exported so the player views, the bot driver and the common board all ask the same question of the
* same function rather than three near-copies of it. Measured before adding it: across six seeds and
* 3,600 decision points the raw field and this never disagreed, because both are only consulted when
* somebody is genuinely acting — so this is a guard against the next reader, not a live fix.
* `clock.currentActor` alone is not it: the engine sets that null while an interruption is standing
* — a §8.1 clearance goes to the Superintendent, a Yard Office offer to the district's owner — so a
* view reading the raw field reports "nobody" during exactly the moments a player is being waited
* on. `actingPlayer` knows that rule and is the engine's own answer to it.
*
* `actingPlayer` alone is not it either, and THIS is what #96 was: it has no status guard, so when
* the game is not running it hands back whatever `clock.currentActor` was left holding — the last
* seat to move before the timetable ran out. The §3.3 vote is the state that exposed it. That vote
* is PARALLEL, open to every un-voted seat at once, and `apply.ts` says in as many words that there
* is no actor to be; the screen named the last mover anyway, beside a tally correctly showing three
* seats outstanding.
*
* So: nobody is acting unless the game is `active`, and when it is, `actingPlayer` decides who.
*
* `currentActor(game)` in `web/game.ts` is this function taking a `Game`, and delegates to it —
* ONE answer, not two that agree until they don't. That matters more than it looks: `currentActor`
* is what refuses an intent, so a screen answering differently tells the table to wait on a player
* the server would turn away.
*/
export function currentActorOfState(s: GameState): PlayerIndex | null {
if (s.status !== 'active') return null;
return actingPlayer(s);
}
+12 -5
View File
@@ -38,6 +38,7 @@ import { cuesFor, narrate } from '../sim/narrate.ts';
import {
cardDescription,
cardName,
currentActorOfState,
describeIntent,
geometryLabel,
snapshot,
@@ -362,12 +363,18 @@ export function drain(game: Game): void {
record(game, pump(game.state));
}
/** Whose turn it is, or null if the game is over or waiting on nothing. */
/**
* Whose turn it is, or null if the game is over or waiting on nothing.
*
* `currentActorOfState` (sim/view.ts) IS this, taking the state rather than the `Game` — so this is
* the adapter and not a second copy. It used to be the second copy: it carried the status guard and
* the view's version did not, which is #96 — the turn chart named the last seat to move all the way
* through the §3.3 vote, while this function correctly refused every intent that seat could send.
* Two functions that agree until they don't are worse than one, because the disagreement surfaces
* as a screen nobody can square with the server.
*/
export function currentActor(game: Game): PlayerIndex | null {
if (game.state.status !== 'active') return null;
// `actingPlayer` (state.ts) knows which player each kind of interruption goes to — the
// Superintendent for a §8.1 clearance, the district's owner for a Yard Office offer.
return actingPlayer(game.state);
return currentActorOfState(game.state);
}
/** Every legal action right now, grouped for display. Empty when there is nothing to decide. */
+68
View File
@@ -24,6 +24,8 @@ import { applyIntent, check } from '../src/engine/apply.ts';
import { STAGES_PER_DAY } from '../src/engine/content.ts';
import { legalActions } from '../src/engine/legal.ts';
import { createGame } from '../src/engine/setup.ts';
import { currentActorOfState, publicSnapshot, snapshot } from '../src/sim/view.ts';
import { currentActor } from '../src/web/game.ts';
import { developerBot, playGame, randomBot } from '../src/sim/bot.ts';
import type { GameConfig, GameState } from '../src/engine/state.ts';
@@ -254,3 +256,69 @@ describe('§3.3 extended play — bots play the timetable they were dealt (Gitea
assert.equal('agree' in choice ? choice.agree : null, false, 'a bot asked for another Day');
});
});
/**
* §3.3 — WHO THE SCREEN SAYS THE TABLE IS WAITING ON, while the vote is open.
*
* The vote is PARALLEL, and `apply.ts` says so where it accepts one: "open to every seat at once:
* it is a table decision rather than a ruling, so THERE IS NO ACTOR TO BE". Any seat that has not
* voted may vote at any moment, in any order, and one refusal ends it. So the honest answer to "who
* are we waiting on" is every un-voted seat — which is exactly what the vote tally beside the chart
* already draws — and the honest answer to "whose turn is it" is nobody.
*
* The engine gave that answer and the screen did not. `currentActor(game)` guards on
* `status !== 'active'` and returned null; `actingPlayer(state)` has no such guard and returned
* `clock.currentActor`, which still holds whoever moved last before the timetable ran out. The
* frame took the second, so the turn chart named one arbitrary seat — the last to act, who has no
* more claim on the vote than anybody else — while the tally underneath correctly showed three
* seats outstanding.
*
* The fourth of these in a row after Gitea#21, #22 and #94, and the first found by asking the
* question of a state the game is not ACTIVE in. See TODO #96.
*/
describe('§3.3 extended play — the vote has no actor (#96)', () => {
const table = (): GameState => {
const s = atTheEnd(game({ mode: 'competitive' }, ['Ann', 'Bob', 'Cy']));
advance(s);
assert.equal(s.status, 'awaitingExtension', 'the table is not being asked');
return s;
};
it('reports nobody acting while the vote is open, to a player and to a spectator alike', () => {
const s = table();
assert.notEqual(s.clock.currentActor, null, 'the premise is gone: nothing was left on the clock');
assert.equal(currentActorOfState(s), null, 'the engine named an actor during a parallel vote');
assert.equal(
snapshot(s, [], null, null, null, false, 0).actor,
null,
'the turn chart named a seat while the whole table was voting',
);
assert.equal(publicSnapshot(s).actor, null, 'the common board named a seat during the vote');
});
it('agrees with the session, which is the half that decides what is legal', () => {
// The disagreement is the bug, not either answer on its own: `currentActor` is what refuses an
// intent, so a screen that names somebody it would refuse is telling the table to wait on a
// player who cannot act.
const s = table();
const g = { state: s, log: [] } as unknown as Parameters<typeof currentActor>[0];
assert.equal(currentActorOfState(s), currentActor(g), 'the screen and the session disagree');
});
it('still names the actor during ordinary play, which is the case that must not regress', () => {
const s = game({ mode: 'competitive' }, ['Ann', 'Bob', 'Cy']);
pump(s);
assert.equal(s.status, 'active', 'the premise is gone: the game is not running');
assert.equal(currentActorOfState(s), s.clock.currentActor, 'an active game lost its actor');
assert.equal(snapshot(s, [], null, null, null, false, 0).actor, s.clock.currentActor);
});
it('reports nobody once the table has declined and the game is finished', () => {
const s = table();
applyIntent(s, 1, { type: 'game.extend', player: 1, agree: false });
assert.equal(s.status, 'finished', 'a refusal did not end it');
assert.equal(currentActorOfState(s), null, 'a finished game still had somebody to move');
assert.equal(publicSnapshot(s).actor, null, 'the common board named a seat after the game ended');
});
});
+76
View File
@@ -531,3 +531,79 @@ describe('§3.3 extended play across the server (Gitea#11)', () => {
assert.deepEqual(b.official, a.official, 'the official result did not survive the replay');
});
});
/**
* NARRATION HAS ONE PATH, AND A RECONNECT HAS TO GET ALL OF IT (#97, Gitea#20 step 1).
*
* The common-board plan asks for one thing here: "stop passing the full game log into `frameFor()`;
* continue sending sanitized incremental narration through `Push.lines`." Doing only the first half
* would have deleted a real behaviour, so this pins the pair.
*
* WHAT WAS ACTUALLY WRONG. `Frame.lines` carried the WHOLE log on every push, and nothing read it:
* `RemoteSession` (`web/session.ts`) accumulates `lines` from `push.lines` alone and its `lines()`
* returns that accumulator. So the log was serialised into every frame for every seat, grew all
* game, and was thrown away on arrival — while `linesSince` sent the same text again, correctly,
* beside it.
*
* And the duplicate was masking a bug rather than merely wasting bandwidth. `connect()` clears
* `lastFrame` but did NOT clear `sentLines`, so a reconnecting seat was told "nothing new since your
* last push" — while the browser it was answering had just reloaded and started from an EMPTY
* accumulator. The history panel came back blank after a refresh, mid-game, with the server holding
* the whole log and shipping it in the one field nobody reads.
*
* So the two halves are one change: a (re)connect resets the seat's watermark and `Push.lines` on a
* connect IS the history, which is what lets the frame stop carrying a second copy.
*/
describe('narration reaches a seat exactly once, by one path (#97)', () => {
/**
* A session with narration already in the log and NO connect yet, so a first connect is a real
* "catch me up" rather than a no-op. Connecting inside this helper is what made the first draft of
* the reconnect test pass vacuously: both sides of the comparison were the empty array.
*/
const played = (): GameSession => createSession(550943578, config, ['Alice', 'Bob']);
it('a FIRST connect carries the narration so far in Push.lines', () => {
const session = createSession(550943578, config, ['Alice', 'Bob']);
const push = session.connect(0 as PlayerIndex);
assert.ok(push.lines.length > 0, 'a first connect was given no narration at all');
assert.ok(
push.lines.some((l) => /players|competitive/i.test(l.text)),
'the opening lines are not in what a first connect received',
);
});
it('a RECONNECT is given the whole log again, because the browser it answers has none', () => {
const session = played();
const first = session.connect(0 as PlayerIndex);
const again = session.connect(0 as PlayerIndex);
// NON-EMPTY first: two empty arrays are deepEqual, and asserting only that is how this test
// passed against the broken code on its first draft.
assert.ok(first.lines.length > 0, 'the premise is gone: there was no narration to be given');
assert.deepEqual(
again.lines,
first.lines,
'a reconnecting seat was told nothing was new, and its history panel would come back empty',
);
});
it('the Frame does NOT carry a second copy of the log', () => {
const session = played();
const push = session.connect(0 as PlayerIndex);
assert.deepEqual(
(push.frame as unknown as { lines: unknown[] }).lines,
[],
'the whole narration log is still being serialised into every Frame, where nothing reads it',
);
});
it('an ordinary push after a connect carries only what is NEW', () => {
const session = played();
const opening = session.connect(0 as PlayerIndex);
assert.ok(opening.lines.length > 0);
// A second connect for the OTHER seat must not re-send seat 0 anything.
const other = session.connect(1 as PlayerIndex);
assert.ok(other.lines.length > 0, 'the other seat got no history of its own');
const third = session.connect(0 as PlayerIndex);
assert.deepEqual(third.lines, opening.lines, 'a reconnect is the full log, every time');
});
});