v0.8.0 — the board replays what everyone else did, instead of arriving rearranged
TODO #13, #15 and #18 — Gitea#20 steps 2-4 pointed at a seated player's own screen. Every accepted intent, and every automatic phase that does anything, becomes an ordered presentation step. A bot's whole switching turn used to land in one push; now it arrives as a run of steps, the district panel follows whoever is acting, and a [N behind] … [Skip] row says how far the board is from the game. Solitaire runs the same path — one collector inside submit(), which both session kinds already funnel through — which is where its automatic phases finally get a visible beat. Dwell is assigned by kind: switching holds the screen, turn bookkeeping costs nothing, and the clock turning over earns the beat. Tunable per viewer without a rebuild, and off entirely at pace 0. Also: switching was the one class of action logging unattributed, and now names its train. Reasoning, measurements and the three things that turned out wrong are in CHANGELOG.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
This commit is contained in:
co-authored by
Claude Opus 5
parent
312e0301e0
commit
02289e94b8
+134
@@ -19,6 +19,140 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 0.8.0 — 2026-09-09
|
||||||
|
|
||||||
|
**Watching the table.** TODO #13, #15 and #18, which is Gitea#20 steps 2-4 pointed at a seated
|
||||||
|
player's own screen. Jesse, 2026-08-29: *"It's not fun to do my turn and have magic happen in the
|
||||||
|
background and then have to figure out what others did."* And 2026-09-09, on what he most wants to
|
||||||
|
see: *"I definitely want to watch other players struggle with the switching exercises … I don't
|
||||||
|
think reading the switching in the log will be anywhere nearly as interesting as watching the trains
|
||||||
|
actually move on the board."*
|
||||||
|
|
||||||
|
The release was scoped in conversation: **0.8.0 is this, 0.8.1 is the seatless board page, 0.9.0 is
|
||||||
|
the Jitsi publisher** — *"13 is the key. Watching on a TV is the bonus."* The design is
|
||||||
|
`docs/plans/jitsi-common-board.md` § v0.8.0.
|
||||||
|
|
||||||
|
### The correction that set the scope
|
||||||
|
|
||||||
|
`Frame.cells` is ONE district — the viewer's own, built from `areaOf(s, viewer)`. So a step stream
|
||||||
|
alone does not answer #13: the data would arrive with nowhere to be drawn. **Rendering a district
|
||||||
|
you do not own is the feature**, not part of the seatless page it had been filed under. Nothing
|
||||||
|
needed to change in `officeSvg` to do it — it takes board data and has never wanted a private
|
||||||
|
viewer, which is why `PublicDistrict` renders as-is.
|
||||||
|
|
||||||
|
### One hook, not two, and replay inert for free
|
||||||
|
|
||||||
|
The design anticipated wiring a collector into `GameSession.intent()` and `driveBots()` separately,
|
||||||
|
with solitaire doing its own thing. It needs neither: `src/server/session.ts` imports `submit` from
|
||||||
|
`src/web/game.ts`, so solitaire, live multiplayer and every bot turn already funnel through one
|
||||||
|
function. That is also what makes this a special case of multiplayer rather than a second
|
||||||
|
implementation.
|
||||||
|
|
||||||
|
And `fromSave`/`fromMultiplayerSave` rebuild a game with `applyIntent` + `record` + `drain` rather
|
||||||
|
than `submit`, so a resumed server does not re-emit a whole game as steps. The plan expected that to
|
||||||
|
need a guard. It needs none — but the property is load-bearing rather than lucky, so it is pinned by
|
||||||
|
test.
|
||||||
|
|
||||||
|
### Pacing, decided by measurement
|
||||||
|
|
||||||
|
The obvious scheme is a time budget divided by the queue length. Measured against real games it does
|
||||||
|
exactly the wrong thing: 44 of a 307-intent game are `draw.end` and 60 are `loadUnload.end`, while
|
||||||
|
the thing worth watching is rare and clustered — two of the three published replays contain no
|
||||||
|
`switch.move` at all, and the third has bursts of **14, 6, 6 and 6**. Six is the engine's own cap per
|
||||||
|
crew, which `trayMoved` says out loud ("N of 6 Moves left"). A uniform budget spends the player's
|
||||||
|
attention on bookkeeping and rushes the switching.
|
||||||
|
|
||||||
|
So dwell is assigned **by kind**: switching 1000ms (Jesse: *"start at 1s and tune down"*), an
|
||||||
|
ordinary action 250ms, a phase 600ms, bookkeeping zero. Three tuning levels, because the committed
|
||||||
|
table needs a web rebuild and in the `.s9pk` that is a release: the table, a per-viewer `pace`
|
||||||
|
multiplier in `Settings` where **0 turns it off**, and a `?pace=` URL parameter for handing two
|
||||||
|
playtesters different speeds. Deliberately **not** in game-creation settings — dwell is presentation,
|
||||||
|
not a rule, and a `GameConfig` rides along in saves and replays.
|
||||||
|
|
||||||
|
**Cost, measured:** about **4.4 minutes of animation across a whole 6-day game**, of which phases are
|
||||||
|
now the largest slice and therefore the first dial to turn.
|
||||||
|
|
||||||
|
### TODO #18 needed a stepped pump, not a delay
|
||||||
|
|
||||||
|
`pump()` runs every automatic phase between one click and the next and `drain()` records the whole
|
||||||
|
batch, so New Train, the Mainline and the shift change were never drawn at all. Folding them into the
|
||||||
|
triggering intent's step reproduced exactly that. `submit()` now steps `advance()` one call at a time
|
||||||
|
and collects per phase; `drain()` is untouched, because replay, undo and `fromSave` all use it and
|
||||||
|
the inertness above depends on their staying off that path.
|
||||||
|
|
||||||
|
**Two obvious rules for what earns a beat were both wrong, and both are now pinned by test.** "No
|
||||||
|
narration, no dwell" looked right and silently killed #18 — a phase can move trains without saying
|
||||||
|
anything. "Anything that changed the board" beat on every turn hand-off, and `submit()` steps
|
||||||
|
`advance()` about 4.6 times per intent, which came to a quarter of an hour a game. The rule is that
|
||||||
|
the **clock turning over** earns the beat.
|
||||||
|
|
||||||
|
### The counter, which is Jesse's design
|
||||||
|
|
||||||
|
*"If I saw the counter as I'm watching the board go 17, 16, 15 … and I got impatient, I could just
|
||||||
|
click a button and have it skip all the rest."* One row rather than three additions — the countdown,
|
||||||
|
#15's caption naming the action being shown, and Skip. It counts only the steps that will actually
|
||||||
|
**dwell**: with bookkeeping at zero, a backlog of 17 where 12 are `*.end` would read "17", plummet to
|
||||||
|
5 instantly and then crawl, which is not a countdown anyone can act on. Skip costs the animation and
|
||||||
|
never the information — every line is already in the History panel.
|
||||||
|
|
||||||
|
This also settled the question the design had left open: an "it's your turn" that arrives while the
|
||||||
|
board is still catching up is confusing, and showing the lag beats both alternatives (holding the
|
||||||
|
turn indicator back, or saying nothing).
|
||||||
|
|
||||||
|
### Switching logged unattributed, and now names its train
|
||||||
|
|
||||||
|
`record()` attributes a line only when the event carries `player`. **`trayMoved`, `carsCoupled`,
|
||||||
|
`carsDropped` and `consistSorted` were the only events in their class that did not** — so a switching
|
||||||
|
turn read as an attributed bracket around anonymous contents: "Player Alice chose to switch / CREW
|
||||||
|
moved (1,2) → (1,3) / Player Alice finished Local Operations". Fixed with the feature that reads
|
||||||
|
those lines rather than filed. Two texts were reworded to compose with the prefix, because
|
||||||
|
`uncapitalise` deliberately protects acronyms and "Player Alice CREW moved" is what it would
|
||||||
|
otherwise have produced. On Jesse's ask the move now names its train — "moved Train 3 (1,2) → (1,3)"
|
||||||
|
— using the existing `trainName`, since a second way of naming a train is the drift this codebase
|
||||||
|
avoids.
|
||||||
|
|
||||||
|
Saves are unaffected: a save is a seed and a list of intents, and events are derived.
|
||||||
|
|
||||||
|
### The delta had to become a true partial
|
||||||
|
|
||||||
|
Steps carry a `PublicFrame` delta. The first version spread `...next` and nulled only the board
|
||||||
|
fields, so every step shipped all 35 top-level properties even when the only change was whose turn it
|
||||||
|
was. Once #18 gave phases their own steps most steps became exactly that, and a full game cost
|
||||||
|
**19.4 MB, of which 16.7 MB was silent steps at ~11 KB each**. As a true partial — only changed
|
||||||
|
fields, only changed districts — the same game is **8.7 MB**. Districts are keyed by seat rather than
|
||||||
|
compared as one array, which alone saves 22%: one intent changes one district, and a whole-array
|
||||||
|
compare resends every other player's board every step.
|
||||||
|
|
||||||
|
### The redaction net grew to cover the steps, and grew two exemptions
|
||||||
|
|
||||||
|
The steps are folded into `everythingSeatSees`, so every existing case covers them — the blind draw,
|
||||||
|
the pending decision, Employee Rotation before and after the seating moves, the reconnect, the
|
||||||
|
played-out game. Doing that surfaced two false positives in the name-based heuristic, **neither
|
||||||
|
caused by this feature**, and the distinction they forced is worth keeping: **a card NAME is
|
||||||
|
circumstantial, a card ID is proof.** Ids are searched everywhere. Names are not searched in two
|
||||||
|
places entitled to carry them — lines naming a **face-up pile** (§2.6: a Department is public, so
|
||||||
|
"Ann discarded Train 6 face-up on Department 3" is the record working, and it stays in the log after
|
||||||
|
she takes it back), and the **accumulated step frames**, which record what was public over time
|
||||||
|
rather than the position now.
|
||||||
|
|
||||||
|
The harness was also passing `g.log` into `snapshot()` for the Frame's own lines, which **production
|
||||||
|
has not done since #97**; now `[]`, matching `frameFor()`.
|
||||||
|
|
||||||
|
**Verified by injecting the v0.7.9.2 blind-draw leak and confirming the net still fails** — both the
|
||||||
|
dedicated test and, independently, the new step coverage. A relaxed safety test that has not been
|
||||||
|
shown to still bite is not a safety test.
|
||||||
|
|
||||||
|
### What is not verified
|
||||||
|
|
||||||
|
The mechanism is proven end to end **server-side**: a real server, a real 3-seat game with two bots,
|
||||||
|
and 28 steps read off a live SSE stream with dense sequence numbers and a 13-step bot burst intact.
|
||||||
|
The page is proven not to throw — `drainIntoQueue`, `renderWatching` and `watchedDistrict` all run
|
||||||
|
under the existing DOM-stub tests. **Nobody has watched it in a browser.** The district switching to a
|
||||||
|
bot's board, the row appearing, and Skip are unexercised, because the stub has no
|
||||||
|
`requestAnimationFrame` and the page degrades to un-animated without one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 0.7.9.8 — 2026-09-07
|
## 0.7.9.8 — 2026-09-07
|
||||||
|
|
||||||
Housekeeping before v0.8.0 — the answer to "anything else that should be looked at first", which
|
Housekeeping before v0.8.0 — the answer to "anything else that should be looked at first", which
|
||||||
|
|||||||
@@ -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
|
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
|
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`
|
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.
|
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
|
- **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
|
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
|
answer them), and real audio. No screen offers a control for the opponent cards any more: the
|
||||||
|
|||||||
@@ -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
|
## 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
|
One shared, seatless display of the public game, usable on a TV or in OBS on its own and publishable
|
||||||
own and publishable into the table's Jitsi meeting. The plan is `docs/plans/jitsi-common-board.md`,
|
into the table's Jitsi meeting. The plan is `docs/plans/jitsi-common-board.md`, seven steps.
|
||||||
seven steps, of which 1-4 are the useful release and 5-7 are the Jitsi publisher.
|
|
||||||
|
**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
|
**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
|
rather than implemented (#103). **The plan was reconciled against the code in v0.7.9.8** and again
|
||||||
which of its "current code findings" are history: it had drifted badly enough to send the next reader
|
on 2026-09-09, and now says which of its "current code findings" are history: it had drifted badly
|
||||||
fixing things twice. Steps 2-7 were never implemented and their findings have NOT been re-verified —
|
enough to send the next reader fixing things twice. Steps 2-7 were never implemented; step 4's
|
||||||
check each before building on it. See **Reference · #103**. Items that look
|
findings were re-verified 2026-09-09 and steps 5-7's were NOT — check each before building on it.
|
||||||
like screen polish live here because they need step 4's ordered presentation mechanism and nothing
|
See **Done · 103**. Items that look like screen polish live here because they need step 4's ordered
|
||||||
cheaper.
|
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
|
- [ ] **#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
|
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
|
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
|
- [ ] **#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
|
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
|
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
|
- [ ] **#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;
|
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
|
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
|
- [ ] **#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**.
|
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
|
Closed items, kept because several are the only record of a ruling or a lesson. Newest first within
|
||||||
each group.
|
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;
|
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.
|
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
|
`# fail 0`. `pretest` is `tsc --noEmit && node scripts/build-web.ts` now, and the same planted
|
||||||
error exits 1 with the tests never running.
|
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 —
|
**Why it mattered THIS week rather than generally.** The next release is steps 2-4 of the
|
||||||
a display stream, credentials, persistence, and a Chromium supervisor — which is almost
|
common board — a display stream, credentials, persistence and the display-step collector, which
|
||||||
entirely `src/server/` and is exactly the half the test command could not see. Found while
|
is almost entirely `src/server/` and is exactly the half the test command could not see. (The
|
||||||
answering "anything else before 0.8.0", which is the only reason it was found at all: nothing
|
Chromium supervisor was in this list when the entry was written; steps 5-7 became v0.9.0 on
|
||||||
about a green suite would ever have said so.
|
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
|
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
|
in v0.7.9.8. `docs/plans/jitsi-common-board.md` was written 2026-08-27, still said "No
|
||||||
|
|||||||
@@ -1,11 +1,53 @@
|
|||||||
# Station Master Jitsi Common Board Implementation Plan
|
# 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
|
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
|
(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.
|
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
|
**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
|
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
|
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
|
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.
|
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
|
## 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.
|
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.
|
6. Supervise one headless Chromium process per published game.
|
||||||
7. Integrate lifecycle, configuration, packaging, health, and live verification.
|
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.
|
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
|
## 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:
|
> **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`
|
> - **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`,
|
> object. Their contents sit at the top level. **All 37 properties, which is the same list as
|
||||||
> `phaseKey`, `actor`, `superintendent`, `deck`, `departments`, `departmentDepth`, `salvage`,
|
> `test/redaction.test.ts`'s allow-list** — `day`, `stage`, `clock` (a time string), `phase`,
|
||||||
> `yards`, `mode`, `days`, `optionalRules`, `houseRules`, `minCombinedRevenue`,
|
> `phaseKey`, `actor`, `superintendent`, `deck`, `departments`, `departmentsWhat`,
|
||||||
> `maxCollisionsPerDay`, `maxCollisionsTotal`, `collisionsToday`, `collisionsTotal`, `status`,
|
> `departmentDepth`, `salvage`, `yards`, `timetable`, `timetableWhat`, `houseRules`, `mode`,
|
||||||
> `outcome`, `extraDays`, `extensionVotes`, `official`, `tally`, `openingRolls`, `timetable`,
|
> `optionalRules`, `days`, `minCombinedRevenue`, `maxCollisionsPerDay`, `maxCollisionsTotal`,
|
||||||
> `timetableWhat`, `trains`, `players`, `division`, `districts`.
|
> `collisionsToday`, `collisionsTotal`, `status`, `outcome`, `extraDays`, `extensionVotes`,
|
||||||
> - **`protocolVersion` was NOT built** and exists nowhere in the repo. Step 2 is the reconnecting
|
> `official`, `tally`, `players`, `openingRolls`, `trains`, `crewTrays`, `queued`, `division`,
|
||||||
> display stream, which is the first thing that would want one — decide there whether to add it,
|
> `districts`. The first 35 come from `projectSharedTable`; `division` and `districts` are added
|
||||||
> rather than assuming it is already on the wire.
|
> 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.
|
> - **`redFlagHeld` is STRUCK OFF**, not deferred. See below.
|
||||||
> - **Fields gained since this was written** that the renderer should know about: `crewTrays` and
|
> - **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
|
> `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.
|
Acceptance requires an allow-list review of every `PublicFrame` property. Passing redaction tests alone is insufficient.
|
||||||
|
|
||||||
## Step 2 — Display credentials, stream, and persistence
|
## 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
|
### Display metadata
|
||||||
|
|
||||||
@@ -305,22 +616,32 @@ For older saves without `display.json`, generate the metadata once on resume and
|
|||||||
|
|
||||||
### HTTP endpoints
|
### HTTP endpoints
|
||||||
|
|
||||||
Add:
|
Add in **0.8.0**:
|
||||||
|
|
||||||
- `GET /display.html#token=<viewToken>` — manual common-board page
|
- `GET /display.html#token=<viewToken>` — manual common-board page
|
||||||
- `GET /api/display/stream?token=<viewToken>` — public-board SSE
|
- `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
|
- `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:
|
Extend the authenticated game/session response with:
|
||||||
|
|
||||||
- display URL
|
- display URL
|
||||||
- Jitsi meeting URL
|
- sanitized publisher state (`'disabled'` until 0.9.0)
|
||||||
- sanitized publisher state
|
|
||||||
|
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.
|
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
|
### 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.
|
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.
|
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
|
### Security requirements
|
||||||
@@ -365,6 +694,15 @@ Cover:
|
|||||||
- display failure not interrupting `/api/intent` or player SSE
|
- display failure not interrupting `/api/intent` or player SSE
|
||||||
|
|
||||||
## Step 3 — Reusable common-board renderer
|
## 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
|
### 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.
|
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
|
## 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
|
### 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.
|
`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.
|
`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.
|
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.
|
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
|
### Failure isolation
|
||||||
@@ -509,6 +886,14 @@ Cover:
|
|||||||
|
|
||||||
## Step 5 — Minimal visual-only Jitsi engine
|
## 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
|
### 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.
|
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
|
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
|
### 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.
|
9. Report normalized lifecycle state over the control channel.
|
||||||
10. Leave and stop all tracks on supervisor command or `pagehide`.
|
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
|
### Jitsi publishing
|
||||||
|
|
||||||
@@ -636,6 +1026,14 @@ Port/adapt the sibling repository’s proven tests for:
|
|||||||
|
|
||||||
## Step 6 — Chromium publisher supervisor
|
## 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
|
### Process model
|
||||||
|
|
||||||
Create one Chromium child process per published game.
|
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
|
- allow a newer engine to supersede only the same session
|
||||||
- reject client/engine role changes on an established socket
|
- reject client/engine role changes on an established socket
|
||||||
- clear pending commands when an engine disconnects
|
- 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.
|
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
|
## 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
|
### Configuration
|
||||||
|
|
||||||
Support:
|
Support:
|
||||||
@@ -844,8 +1251,10 @@ Exclude all tokens, full control frames, player private state, query strings, an
|
|||||||
|
|
||||||
Add runtime dependencies:
|
Add runtime dependencies:
|
||||||
|
|
||||||
- the pinned `lib-jitsi-meet` release
|
- the pinned `lib-jitsi-meet` release, vendored as a browser asset rather than an npm runtime import
|
||||||
- `ws`
|
|
||||||
|
**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.
|
Add Chromium and `tini` to the Station Master runtime image or companion StartOS packaging repository.
|
||||||
|
|
||||||
@@ -912,7 +1321,7 @@ Capture:
|
|||||||
|
|
||||||
- raw display SSE
|
- raw display SSE
|
||||||
- rendered canvas screenshots
|
- rendered canvas screenshots
|
||||||
- control WebSocket messages
|
- control-channel messages
|
||||||
- Jitsi network destinations
|
- Jitsi network destinations
|
||||||
- server logs
|
- server logs
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "station-master",
|
"name": "station-master",
|
||||||
"version": "0.7.9.8",
|
"version": "0.8.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Station Master — a railroad operations game",
|
"description": "Station Master — a railroad operations game",
|
||||||
|
|||||||
+4
-1
@@ -1642,6 +1642,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
|||||||
const events: GameEvent[] = [
|
const events: GameEvent[] = [
|
||||||
{
|
{
|
||||||
type: 'trayMoved',
|
type: 'trayMoved',
|
||||||
|
player,
|
||||||
trayId: i.trayId,
|
trayId: i.trayId,
|
||||||
from,
|
from,
|
||||||
to: i.to,
|
to: i.to,
|
||||||
@@ -1706,6 +1707,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
|||||||
// decides which car is next to come off.
|
// decides which car is next to come off.
|
||||||
events.push({
|
events.push({
|
||||||
type: 'carsCoupled',
|
type: 'carsCoupled',
|
||||||
|
player,
|
||||||
trayId: i.trayId,
|
trayId: i.trayId,
|
||||||
at: i.to,
|
at: i.to,
|
||||||
stock: dest.couples,
|
stock: dest.couples,
|
||||||
@@ -1726,7 +1728,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
|||||||
const stock = i.fromNose
|
const stock = i.fromNose
|
||||||
? tray.consist.slice(0, i.count)
|
? tray.consist.slice(0, i.count)
|
||||||
: tray.consist.slice(tray.consist.length - 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': {
|
case 'switch.sortConsist': {
|
||||||
@@ -1735,6 +1737,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
type: 'consistSorted',
|
type: 'consistSorted',
|
||||||
|
player,
|
||||||
trayId: i.trayId,
|
trayId: i.trayId,
|
||||||
at: here,
|
at: here,
|
||||||
before: tray.consist.map((c) => ({ ...c })),
|
before: tray.consist.map((c) => ({ ...c })),
|
||||||
|
|||||||
@@ -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
|
* 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.
|
* 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';
|
type: 'carsCoupled';
|
||||||
|
player: PlayerIndex;
|
||||||
trayId: TrayId;
|
trayId: TrayId;
|
||||||
at: GridCoord;
|
at: GridCoord;
|
||||||
stock: RollingStock[];
|
stock: RollingStock[];
|
||||||
@@ -71,8 +72,8 @@ export type GameEvent =
|
|||||||
*/
|
*/
|
||||||
recoupled?: { at: GridCoord; stock: RollingStock[] };
|
recoupled?: { at: GridCoord; stock: RollingStock[] };
|
||||||
}
|
}
|
||||||
| { type: 'carsDropped'; trayId: TrayId; at: GridCoord; stock: RollingStock[]; fromNose?: boolean }
|
| { type: 'carsDropped'; player: PlayerIndex; trayId: TrayId; at: GridCoord; stock: RollingStock[]; fromNose?: boolean }
|
||||||
| { type: 'consistSorted'; trayId: TrayId; at: GridCoord; before: RollingStock[]; after: RollingStock[] }
|
| { type: 'consistSorted'; player: PlayerIndex; trayId: TrayId; at: GridCoord; before: RollingStock[]; after: RollingStock[] }
|
||||||
| { type: 'cardDrawn'; player: PlayerIndex; source: 'homeOffice' | 'department'; slot?: number; cardId: CardId }
|
| { 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
|
* §6.2 — the Home Office deck ran out, so the Salvage Yard and all three Department decks were
|
||||||
|
|||||||
+55
-5
@@ -26,8 +26,10 @@ import { actionMenu, currentActor, fromMultiplayerSave, isOutOfTurn, newMultipla
|
|||||||
import type { Game, Menu } from '../web/game.ts';
|
import type { Game, Menu } from '../web/game.ts';
|
||||||
import { deltaFrame } from '../sim/frame-delta.ts';
|
import { deltaFrame } from '../sim/frame-delta.ts';
|
||||||
import type { FrameDelta } from '../sim/frame-delta.ts';
|
import type { FrameDelta } from '../sim/frame-delta.ts';
|
||||||
import { snapshot, seatLabel } from '../sim/view.ts';
|
import { publicSnapshot, snapshot, seatLabel } from '../sim/view.ts';
|
||||||
import type { Frame } 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';
|
import { developerBot } from '../sim/bot.ts';
|
||||||
|
|
||||||
export type Push = {
|
export type Push = {
|
||||||
@@ -69,6 +71,29 @@ export type Push = {
|
|||||||
scheduled?: number | null;
|
scheduled?: number | null;
|
||||||
announcement?: string | null;
|
announcement?: string | null;
|
||||||
justDrawn?: 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 };
|
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 frame = frameFor(seat);
|
||||||
const delta = deltaFrame(lastFrame.get(seat) ?? null, frame);
|
const delta = deltaFrame(lastFrame.get(seat) ?? null, frame);
|
||||||
lastFrame.set(seat, frame);
|
lastFrame.set(seat, frame);
|
||||||
const push: Push = { frame: delta, menu: menuFor(seat), lines: linesSince(seat) };
|
const push: Push = { frame: delta, menu: menuFor(seat), lines: linesSince(seat) };
|
||||||
|
if (steps.length > 0) push.steps = steps;
|
||||||
if (moment) {
|
if (moment) {
|
||||||
if (moment.cues.length > 0) push.cues = moment.cues;
|
if (moment.cues.length > 0) push.cues = moment.cues;
|
||||||
if (moment.scheduled !== null) push.scheduled = moment.scheduled;
|
if (moment.scheduled !== null) push.scheduled = moment.scheduled;
|
||||||
@@ -228,9 +254,13 @@ function buildSession(
|
|||||||
|
|
||||||
function pushesForAll(): Map<PlayerIndex, Push> {
|
function pushesForAll(): Map<PlayerIndex, Push> {
|
||||||
const moment = takeMoment();
|
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>();
|
const out = new Map<PlayerIndex, Push>();
|
||||||
for (let seat = 0; seat < playerNames.length; seat++) {
|
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;
|
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
|
// 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.)
|
// bound on a server, which nothing was draining before this.)
|
||||||
takeMoment();
|
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 {
|
return {
|
||||||
playerCount: playerNames.length,
|
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
|
// 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.
|
// connect IS the history, which is what lets the Frame stop carrying a second copy.
|
||||||
sentLines.delete(seat);
|
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) {
|
intent(seat, seq, i) {
|
||||||
|
|||||||
@@ -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
@@ -155,7 +155,7 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
|||||||
return {
|
return {
|
||||||
tone: 'plain',
|
tone: 'plain',
|
||||||
where: e.to,
|
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': {
|
case 'carsCoupled': {
|
||||||
/**
|
/**
|
||||||
@@ -181,7 +181,7 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
|||||||
return {
|
return {
|
||||||
tone: 'good',
|
tone: 'good',
|
||||||
where: e.at,
|
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':
|
case 'carsDropped':
|
||||||
// WHICH END. A cut comes off an outer end (§A.3) and the end decides everything that follows:
|
// WHICH END. A cut comes off an outer end (§A.3) and the end decides everything that follows:
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -23,7 +23,7 @@
|
|||||||
* folding events does not rebuild a game — `protocol.md` §3.)
|
* 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 { applyIntent } from '../engine/apply.ts';
|
||||||
import type { GameEvent } from '../engine/events.ts';
|
import type { GameEvent } from '../engine/events.ts';
|
||||||
import type { Intent } from '../engine/intents.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 { overHandLimit as overHandLimitOf } from '../engine/state.ts';
|
||||||
import { playerAtSeat } from '../engine/state.ts';
|
import { playerAtSeat } from '../engine/state.ts';
|
||||||
import { cuesFor, narrate } from '../sim/narrate.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,
|
// 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.
|
// which would pull node:fs into a browser bundle.
|
||||||
import {
|
import {
|
||||||
@@ -276,6 +278,16 @@ export type Game = {
|
|||||||
* having taken a turn to cause it.
|
* having taken a turn to cause it.
|
||||||
*/
|
*/
|
||||||
announced: string | null;
|
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. */
|
/** 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 {
|
export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
|
||||||
const state = createGame({ id: `web-${seed}`, seed, config, playerNames: [SOLO_PLAYER] });
|
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
|
// A history that opens mid-Stage reads as though something was missed. Say what the game IS
|
||||||
// first, then let the clock take over.
|
// first, then let the clock take over.
|
||||||
game.log.push({ text: 'Game Begins', tone: 'start' });
|
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 {
|
export function newMultiplayerGame(seed: number, config: GameConfig, playerNames: string[]): Game {
|
||||||
const state = createGame({ id: `mp-${seed}`, seed, config, playerNames });
|
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' });
|
game.log.push({ text: 'Game Begins', tone: 'start' });
|
||||||
/**
|
/**
|
||||||
* NO SEED AT A TABLE WITH MORE THAN ONE SEAT (Gitea#20 step 1).
|
* 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;
|
return false;
|
||||||
}
|
}
|
||||||
game.history.push(intent);
|
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);
|
record(game, result.events, actor);
|
||||||
drain(game);
|
collectStep(game.display, game.state, actor, intent.type, game.log.slice(saidFrom));
|
||||||
|
drainStepping(game);
|
||||||
return true;
|
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.
|
* Which cards in hand can be played RIGHT NOW, in hand order.
|
||||||
*
|
*
|
||||||
|
|||||||
+204
-4
@@ -24,6 +24,8 @@ import type { NewGameOptions } from './game.ts';
|
|||||||
import type { LocalSession, Session } from './session.ts';
|
import type { LocalSession, Session } from './session.ts';
|
||||||
import { createLocalSession, createRemoteSession } from './session.ts';
|
import { createLocalSession, createRemoteSession } from './session.ts';
|
||||||
import type { PlayerIndex } from '../engine/state.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 { notice, prefillCode, runLobby } from './lobby.ts';
|
||||||
import type { LobbyReady } from './lobby.ts';
|
import type { LobbyReady } from './lobby.ts';
|
||||||
import {
|
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".
|
* to?", which Jesse's own framing says is "not something they're likely to need all the time".
|
||||||
*/
|
*/
|
||||||
gameCardOpen: boolean;
|
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 {
|
function loadSettings(): Settings {
|
||||||
try {
|
try {
|
||||||
@@ -87,6 +123,11 @@ function loadSettings(): Settings {
|
|||||||
: DEFAULT_SETTINGS.zoom,
|
: DEFAULT_SETTINGS.zoom,
|
||||||
gameCardOpen:
|
gameCardOpen:
|
||||||
typeof parsed.gameCardOpen === 'boolean' ? parsed.gameCardOpen : DEFAULT_SETTINGS.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 {
|
} catch {
|
||||||
// A full or disabled localStorage must not take the game down with it — same guard as the save.
|
// 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;
|
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
|
* 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
|
* `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
|
// 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
|
// for `subscribe`'s callback rather than firing immediately (`session.ts`'s own doc comment on
|
||||||
// `createRemoteSession` explains why `view()` would otherwise throw).
|
// `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();
|
applyCapabilities();
|
||||||
// Every render goes through the session, so the page redraws whenever the game says it changed —
|
// 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.
|
// 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();
|
render();
|
||||||
// Coming back to a game is not the same event as being dealt one, and the board looks identical
|
// 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).
|
// either way — mid-Day, mid-phase, with a log already deep (Jesse, 2026-08-30).
|
||||||
@@ -1073,6 +1257,7 @@ function render(): void {
|
|||||||
|
|
||||||
renderTurnChart(f);
|
renderTurnChart(f);
|
||||||
renderPresence(f);
|
renderPresence(f);
|
||||||
|
renderWatching();
|
||||||
$('revenue').textContent = String(f.revenue);
|
$('revenue').textContent = String(f.revenue);
|
||||||
/**
|
/**
|
||||||
* THE OBJECTIVE, WITHOUT THE COMMENTARY.
|
* THE OBJECTIVE, WITHOUT THE COMMENTARY.
|
||||||
@@ -1139,9 +1324,23 @@ function render(): void {
|
|||||||
: 'ATTACH TO THIS CARD';
|
: 'ATTACH TO THIS CARD';
|
||||||
return [{ row: cell.row, col: cell.col, label }];
|
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);
|
applyZoom(grid);
|
||||||
|
|
||||||
|
if (!watched) {
|
||||||
// Wire the roster chips: clicking one sets `selectedCrew`, the same value the "Which train are
|
// 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.
|
// 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]'))) {
|
for (const g of Array.from(grid.querySelectorAll('g[data-crew]'))) {
|
||||||
@@ -1203,6 +1402,7 @@ function render(): void {
|
|||||||
g.setAttribute('data-tip', `${own}\n\n${head}: ${b.why}`);
|
g.setAttribute('data-tip', `${own}\n\n${head}: ${b.why}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function pick(key: string, list: { label: string; index: number }[]): void {
|
function pick(key: string, list: { label: string; index: number }[]): void {
|
||||||
if (list.length === 1) {
|
if (list.length === 1) {
|
||||||
|
|||||||
+22
-1
@@ -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:last-child{border-bottom:none}
|
||||||
.lb-seat .who{flex:1}
|
.lb-seat .who{flex:1}
|
||||||
#presence{color:#e0b060;font-size:12px;padding:0 14px;empty-cells:hide}
|
#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}
|
#presence:empty{display:none}
|
||||||
/* division strip */
|
/* division strip */
|
||||||
#division{display:flex;gap:7px;overflow-x:auto;padding-bottom:4px}
|
#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. -->
|
collapsed whenever everyone connected is still connected — a `LocalSession` never fills it. -->
|
||||||
<div id="presence"></div>
|
<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>
|
<main>
|
||||||
<div>
|
<div>
|
||||||
<section><h2>The Division — west to east</h2><div id="division"></div>
|
<section><h2>The Division — west to east</h2><div id="division"></div>
|
||||||
<p class="ng-note" id="seating-chain"></p></section>
|
<p class="ng-note" id="seating-chain"></p></section>
|
||||||
<section id="district">
|
<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 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>
|
<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>
|
</h2>
|
||||||
|
|||||||
+70
-1
@@ -16,7 +16,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Intent } from '../engine/intents.ts';
|
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 type { PlayerIndex } from '../engine/state.ts';
|
||||||
import { applyDelta } from '../sim/frame-delta.ts';
|
import { applyDelta } from '../sim/frame-delta.ts';
|
||||||
import type { FrameDelta } 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.
|
* starts — which is exactly when "is everyone here?" is the question.
|
||||||
*/
|
*/
|
||||||
presence(): { seat: PlayerIndex; connected: boolean; seen: boolean }[];
|
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.
|
* Stop listening, for good.
|
||||||
*
|
*
|
||||||
@@ -145,6 +170,12 @@ export type LocalSession = Session & {
|
|||||||
*/
|
*/
|
||||||
export function createLocalSession(seed: number, options?: NewGameOptions): LocalSession {
|
export function createLocalSession(seed: number, options?: NewGameOptions): LocalSession {
|
||||||
let game: Game = options ? newGame(seed, configWith(options)) : newGame(seed);
|
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 listeners = new Set<() => void>();
|
||||||
const changed = (): void => {
|
const changed = (): void => {
|
||||||
for (const fn of [...listeners]) fn();
|
for (const fn of [...listeners]) fn();
|
||||||
@@ -186,6 +217,14 @@ export function createLocalSession(seed: number, options?: NewGameOptions): Loca
|
|||||||
},
|
},
|
||||||
justDrawn: () => game.justDrawn,
|
justDrawn: () => game.justDrawn,
|
||||||
presence: () => [],
|
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,
|
seed: () => game.seed,
|
||||||
save: () => toSave(game),
|
save: () => toSave(game),
|
||||||
@@ -199,6 +238,8 @@ export function createLocalSession(seed: number, options?: NewGameOptions): Loca
|
|||||||
back.scheduled = null;
|
back.scheduled = null;
|
||||||
back.justDrawn = null;
|
back.justDrawn = null;
|
||||||
back.announced = 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();
|
changed();
|
||||||
return true;
|
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.
|
// Restoring replays the whole history and re-records every draw; none of it is news.
|
||||||
game.justDrawn = null;
|
game.justDrawn = null;
|
||||||
game.announced = null;
|
game.announced = null;
|
||||||
|
pendingReset = publicSnapshot(game.state);
|
||||||
changed();
|
changed();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -220,6 +262,10 @@ type Push = {
|
|||||||
lines: { text: string; tone: string }[];
|
lines: { text: string; tone: string }[];
|
||||||
/** One entry for a change; every other seat at once on the connect push. */
|
/** One entry for a change; every other seat at once on the connect push. */
|
||||||
presence?: { seat: PlayerIndex; connected: boolean; seen: boolean }[];
|
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.
|
* THE FOUR TRANSIENT SIGNALS, added 2026-08-23.
|
||||||
*
|
*
|
||||||
@@ -270,6 +316,8 @@ export function createRemoteSession(
|
|||||||
let menu: Menu | null = null;
|
let menu: Menu | null = null;
|
||||||
let lines: { text: string; tone: string }[] = [];
|
let lines: { text: string; tone: string }[] = [];
|
||||||
const presence = new Map<PlayerIndex, { connected: boolean; seen: boolean }>();
|
const presence = new Map<PlayerIndex, { connected: boolean; seen: boolean }>();
|
||||||
|
let displaySteps: DisplayStep[] = [];
|
||||||
|
let displayReset: PublicFrame | null = null;
|
||||||
let cues: string[] = [];
|
let cues: string[] = [];
|
||||||
let scheduled: number | null = null;
|
let scheduled: number | null = null;
|
||||||
let announcement: string | null = null;
|
let announcement: string | null = null;
|
||||||
@@ -324,6 +372,21 @@ export function createRemoteSession(
|
|||||||
if (push.announcement !== undefined && push.announcement !== null) announcement = push.announcement;
|
if (push.announcement !== undefined && push.announcement !== null) announcement = push.announcement;
|
||||||
// Persists until another draw replaces it, matching the local session's own `justDrawn`.
|
// Persists until another draw replaces it, matching the local session's own `justDrawn`.
|
||||||
if (push.justDrawn !== undefined) justDrawnCard = push.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();
|
changed();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -376,6 +439,12 @@ export function createRemoteSession(
|
|||||||
},
|
},
|
||||||
justDrawn: () => justDrawnCard,
|
justDrawn: () => justDrawnCard,
|
||||||
presence: () => [...presence].map(([seat, p]) => ({ seat, connected: p.connected, seen: p.seen })),
|
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() {
|
close() {
|
||||||
// `reportedGone` first: closing the stream fires `onerror`, and this is a deliberate exit, not
|
// `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
|
// a game that vanished — `onGone` must not be called and land the page in "that game is no
|
||||||
|
|||||||
@@ -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
@@ -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 build = (toNose: boolean): string[] => {
|
||||||
const s = game();
|
const s = game();
|
||||||
const id = placeTray(s, at(0, 0), [car('boxcar')] as never);
|
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);
|
return s.trays.get(id)!.consist.map((c) => c.type);
|
||||||
};
|
};
|
||||||
assert.deepEqual(build(true), ['hopper', 'boxcar'], 'running forward takes cars on the nose');
|
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)!;
|
const tray = s.trays.get(id)!;
|
||||||
tray.engineAt = 0;
|
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.equal(tray.engineAt, 1, 'the engine should now have a car ahead of it');
|
||||||
assert.deepEqual(tray.consist.map((c) => c.type), ['hopper', 'boxcar']);
|
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.equal(tray.engineAt, 0, 'setting out the nose cars puts the engine back in front');
|
||||||
assert.deepEqual(tray.consist.map((c) => c.type), ['boxcar']);
|
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.
|
* So `facing` stays a PORT (movement needs one) and `railFacingOf` is what the board draws.
|
||||||
*/
|
*/
|
||||||
const moved = (id: string, facing: 'n' | 's' | 'e' | 'w') =>
|
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', () => {
|
it('carries the east-west sense across north-south track', () => {
|
||||||
const s = game();
|
const s = game();
|
||||||
|
|||||||
@@ -24,12 +24,13 @@ import { impediments, narrate } from '../src/sim/narrate.ts';
|
|||||||
import { readFileSync, readdirSync } from 'node:fs';
|
import { readFileSync, readdirSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { actionMenu } from '../src/web/game.ts';
|
import { actionMenu } from '../src/web/game.ts';
|
||||||
|
import { newCollector } from '../src/sim/display-step.ts';
|
||||||
import type { Game } from '../src/web/game.ts';
|
import type { Game } from '../src/web/game.ts';
|
||||||
|
|
||||||
/** The thin wrapper `actionMenu` expects, built directly around an already-created multi-player state
|
/** 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. */
|
* — `newGame` (game.ts) hardcodes one player, so it cannot construct this for a multi-seat game. */
|
||||||
const wrap = (s: GameState): 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 = {
|
const competitive: GameConfig = {
|
||||||
mode: 'competitive',
|
mode: 'competitive',
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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', () => {
|
describe('#91 — nothing private survives serialisation, in any state', () => {
|
||||||
const names = ['Ann', 'Bob', 'Cy'];
|
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 =>
|
* Everything one seat can see, split into the two halves the checks below treat differently.
|
||||||
JSON.stringify(snapshot(g.state, g.log, null, null, null, false, seat)) +
|
*
|
||||||
'\n' + JSON.stringify(publicSnapshot(g.state)) +
|
* `structural` is the machine-readable state: their Frame, the public board, and the frame of every
|
||||||
'\n' + g.log.map((l) => l.text).join('\n');
|
* 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
|
* 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
|
* 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.
|
* was in the log.
|
||||||
*/
|
*/
|
||||||
const secretsOfOthers = (g: ReturnType<typeof newMultiplayerGame>, seat: PlayerIndex): { what: string; value: string }[] => {
|
const secretsOfOthers = (
|
||||||
const out: { what: string; value: string }[] = [];
|
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.
|
// 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>();
|
const totalByName = new Map<string, number>();
|
||||||
for (const id of g.state.cards.keys()) {
|
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);
|
heldByName.set(n, (heldByName.get(n) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
for (const id of hand) {
|
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);
|
const name = cardName(g.state, id);
|
||||||
if (totalByName.get(name) === heldByName.get(name)) {
|
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. */
|
/** Runs the whole net over one state, and says which state failed if it does. */
|
||||||
const audit = (g: ReturnType<typeof newMultiplayerGame>, where: string): void => {
|
const audit = (g: ReturnType<typeof newMultiplayerGame>, where: string): void => {
|
||||||
for (const seat of g.state.players.map((p) => p.index)) {
|
for (const seat of g.state.players.map((p) => p.index)) {
|
||||||
const seen = everythingSeatSees(g, seat);
|
const { structural, history, narration } = everythingSeatSees(g, seat);
|
||||||
for (const { what, value } of secretsOfOthers(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(
|
assert.ok(
|
||||||
!seen.includes(value),
|
!(precise ? everything : forNames).includes(value),
|
||||||
`${where}: seat ${seat} can see ${what} ("${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.
|
// 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.
|
// And the spectator board, which has no seat and is therefore entitled to nothing private.
|
||||||
const pub = JSON.stringify(publicSnapshot(g.state));
|
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');
|
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', () => {
|
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
|
// 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
|
// 7", in every coordinate and in half the numbers on the board — so it reported a leak that was
|
||||||
|
|||||||
+56
-9
@@ -6,6 +6,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it } from 'node:test';
|
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 assert from 'node:assert/strict';
|
||||||
|
|
||||||
import { pump } from '../src/engine/advance.ts';
|
import { pump } from '../src/engine/advance.ts';
|
||||||
@@ -42,9 +45,9 @@ const SAMPLES: GameEvent[] = [
|
|||||||
{ type: 'phaseBegan', phase: 'mainline' },
|
{ type: 'phaseBegan', phase: 'mainline' },
|
||||||
{ type: 'actorChanged', player: 0 },
|
{ type: 'actorChanged', player: 0 },
|
||||||
{ type: 'localOpsOptionChosen', player: 0, option: 'switch' },
|
{ type: 'localOpsOptionChosen', player: 0, option: 'switch' },
|
||||||
{ type: 'trayMoved', trayId: 't0', from: { row: 0, col: 0 }, to: { row: 0, col: 1 }, movesRemaining: 5 },
|
{ type: 'trayMoved', player: 0, 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: 'carsCoupled', player: 0, 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: 'carsDropped', player: 0, trayId: 't0', at: { row: 1, col: 0 }, stock: [{ type: 'hopper', loaded: false }] },
|
||||||
{ type: 'cardDrawn', player: 0, source: 'homeOffice', cardId: 'c1' },
|
{ type: 'cardDrawn', player: 0, source: 'homeOffice', cardId: 'c1' },
|
||||||
{ type: 'cardPlayed', player: 0, cardId: 'c1', placement: { row: 1, col: 0 }, variant: 0 },
|
{ type: 'cardPlayed', player: 0, cardId: 'c1', placement: { row: 1, col: 0 }, variant: 0 },
|
||||||
{ type: 'officeUpgraded', player: 0, from: 'whistlePost', to: 'depot' },
|
{ type: 'officeUpgraded', player: 0, from: 'whistlePost', to: 'depot' },
|
||||||
@@ -72,12 +75,56 @@ const SAMPLES: GameEvent[] = [
|
|||||||
|
|
||||||
describe('narration', () => {
|
describe('narration', () => {
|
||||||
it('covers every event type the engine can emit', () => {
|
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));
|
* THIS TEST USED TO BUILD BOTH SETS FROM `SAMPLES` and compare them to each other, so it could
|
||||||
const declared = new Set<string>();
|
* only ever assert that the sample list had 30 distinct entries — the one thing it could not
|
||||||
for (const e of SAMPLES) declared.add(e.type);
|
* detect was the thing its comment promised, a new `GameEvent` slipping in unnarrated. Fixed
|
||||||
assert.equal(covered.size, 30, 'sample list is out of step with GameEvent');
|
* 2026-09-09 while adding the v0.8.0 step collector, which made the event union load-bearing for
|
||||||
assert.equal(declared.size, 30);
|
* 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', () => {
|
it('gives every event a specific, non-empty sentence', () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user