Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
072029b1f7 | ||
|
|
d0e5091824 | ||
|
|
64e8ce584f | ||
|
|
3fca325699 | ||
|
|
fc40fc39ed | ||
|
|
ff629c0708 | ||
|
|
c10f52791e | ||
|
|
0cfeb4c496 | ||
|
|
02289e94b8 |
+517
@@ -19,6 +19,523 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
||||
|
||||
---
|
||||
|
||||
## 0.8.0.8 — 2026-09-10
|
||||
|
||||
**A played train does not come back. Gitea#23, ruled and closed.**
|
||||
|
||||
Jesse, on the question v0.8.0.7 filed rather than answered: *"Once you've played a regularly
|
||||
scheduled train and it's in the salvage deck, that train is already on the timetable. It does not
|
||||
make sense to put that back into a reshuffled home deck to get played again. By contrast, a regularly
|
||||
scheduled train that's in a discard pile could potentially get reused later, and so should have that
|
||||
capability. Extras run one time and then they're done — if they are in the Salvage deck, they should
|
||||
get shuffled back in so that they could get run again."*
|
||||
|
||||
**The test is WHERE the card is, not only what it is** — which is the part worth writing down, because
|
||||
it is exactly the rule a later tidy-up would "simplify" into filtering by card kind everywhere. The
|
||||
same train card is spent in the Salvage Yard and still runnable in a Department:
|
||||
|
||||
| card | where | on a reshuffle |
|
||||
| --- | --- | --- |
|
||||
| timetabled train | Salvage Yard — it was **played**, its number is on the timetable | stays out |
|
||||
| timetabled train | a Department — **discarded**, never played, slot still open | comes back |
|
||||
| Extra | anywhere | comes back; an Extra is one run, not a standing slot |
|
||||
| everything else | anywhere | comes back, as before |
|
||||
|
||||
### And the duplicate that started it
|
||||
|
||||
`trainScheduled` was pushing a synthetic `train-<number>` into the Salvage Yard **beside the real
|
||||
card `cardPlayed` had already put there** — measured: four scheduled trains left eight entries in a
|
||||
pile holding four cards. Nothing read that id. It inflated the pile's depth, displayed as "a card"
|
||||
because no such card exists, and would have been swept into the draw deck to be drawn as an id with
|
||||
nothing behind it. Removed, which retires the whole phantom-id class rather than papering over it —
|
||||
so v0.8.0.7's `cardName()` resolver for `train-<n>` is gone too, along with its test. Dead code kept
|
||||
for an id that can no longer exist is worse than no code.
|
||||
|
||||
### Games in progress
|
||||
|
||||
**Resume.** No predicate changed its answer — nothing that was legal became illegal, and a draw is a
|
||||
draw whatever is on top of the deck. What differs is the Salvage Yard's depth, which was
|
||||
double-counting, and what a reshuffle would recover. Reshuffles are effectively unreachable in
|
||||
ordinary play: eight games driven to 4000 moves across eight seeds produced zero.
|
||||
|
||||
Closes #23.
|
||||
|
||||
---
|
||||
|
||||
## 0.8.0.7 — 2026-09-10
|
||||
|
||||
### The Salvage Yard was face up and had nothing to say
|
||||
|
||||
*"Why is salvage deck not face up? I should see the card played onto salvage."* It always was — the
|
||||
tile reads the top card. It just said **"a card"**.
|
||||
|
||||
`apply.ts` pushes a **synthetic id** on `trainScheduled`:
|
||||
|
||||
```ts
|
||||
s.decks.salvageYard.push(`train-${e.trainNumber}`);
|
||||
```
|
||||
|
||||
Nothing in `s.cards` matches that, so `cardName()` fell through to its "a card" default — and since a
|
||||
train is scheduled several times a Day, that id is on top of the pile most of the time. Measured
|
||||
before touching anything: a session's Salvage tile read `"a card"` from the opening frame through 60
|
||||
pushes, never once changing, while its depth climbed from 2 to 8.
|
||||
|
||||
`cardName()` resolves `train-<n>` now, so the tile reads **Train 3**, **Train 2** as cards land.
|
||||
Resolved in `sim/view.ts` because this is a NAME, which is that file's job.
|
||||
|
||||
**The engine half is filed, not fixed — Gitea#23.** `reshuffleIfDepleted()` sweeps the Salvage Yard
|
||||
back into the draw deck, so a synthetic id can be shuffled in and drawn into a hand as an id with no
|
||||
card behind it. Not reachable in ordinary play: eight games driven to 4000 moves across eight seeds
|
||||
produced **zero** reshuffles. There are two defensible fixes and the choice turns on what that
|
||||
synthetic id is *for*, which is not a call to make in passing while fixing a label.
|
||||
|
||||
### Phases scale with the speed control again — at a third of the rate
|
||||
|
||||
*"Phases displayed on the upper line go by too quickly still. Should be 4 times as long — at a guess.
|
||||
Maybe use the speed multiplier for that too?"*
|
||||
|
||||
Two complaints from opposite directions, and the answer is between them. v0.8.0.3 **pinned** phases at
|
||||
their tabled beat because scaling them walled off a player's own turn — *"that makes no sense"*. At
|
||||
10× that pinned beat is too short to read the sentence on it.
|
||||
|
||||
So they scale, damped to a third of the rate: **1× unchanged, 10× lands exactly on the four-times
|
||||
guess**, 20× gives 4400ms. The cost stays bounded because phase beats cluster rather than accumulate
|
||||
— measured over 60 pushes, a push carries **1.0 phase beat on average and 4 at worst**, so the wait
|
||||
after a move is ~2.4s typical and ~10s at its very worst, against the minutes a full multiplier would
|
||||
have cost. A player's own move still outlasts a phase beat at every speed, which is the ordering that
|
||||
matters.
|
||||
|
||||
---
|
||||
|
||||
## 0.8.0.6 — 2026-09-10
|
||||
|
||||
Playing v0.8.0.5: *"saw bot's office area now — much better."* Three things still wrong, and one of
|
||||
them was mine hiding inside the fix for another.
|
||||
|
||||
### Your move is put away while the board is catching up
|
||||
|
||||
*"Your actions should be hidden while catching up."* Two reasons, and the second is the one that
|
||||
changed my mind about a decision taken early in v0.8.0 ("never block input"). The board on screen is
|
||||
behind the game, so a move offered there is a move against a position that has already moved on — the
|
||||
menu is computed from the CURRENT state and would be acted on while looking at an older one. And the
|
||||
screen had grown to four things competing at once: the district, the history, the catching-up row,
|
||||
and now a lit pile. Taking the action list out of that competition, while there is nothing to decide
|
||||
anyway, is the cheapest way to quieten it.
|
||||
|
||||
Not a block: Skip is one click away at the left of the row, so the wait stays voluntary. The buttons
|
||||
are replaced by the reason they are gone.
|
||||
|
||||
### …which could have locked a player out of their own game
|
||||
|
||||
Hiding actions behind `busy()` makes that flag the thing standing between a player and their turn —
|
||||
and **without `requestAnimationFrame` nothing ever advances the queue, so `busy()` would never
|
||||
clear.** The action list would have been hidden permanently, with Skip the only way to play.
|
||||
|
||||
Caught by `test/web.test.ts`, whose DOM stub has no `rAF` — the same stub that has been proving this
|
||||
page still starts since long before any of this existed. Two fallbacks now: no `rAF` means draw
|
||||
everything at once (exactly what `pace = 0` does deliberately), and a queue that throws empties
|
||||
itself rather than stranding the player. `test/step-queue.test.ts` pins that a never-advanced queue
|
||||
is still skippable.
|
||||
|
||||
### The lit pile was never brief — it was too quiet
|
||||
|
||||
*"Never saw decks lighting up… caught one flash deck light up for just a very brief moment, but
|
||||
couldn't see that with what bot was doing in office area and history and catch up area all at same
|
||||
time."*
|
||||
|
||||
Measured before changing anything: at 10× a pile stays lit for **6997ms**, just under seven seconds.
|
||||
So the highlight was not brief at all. It was a single 0.45s flash-in over a dark green fill, easy to
|
||||
miss entirely while looking at the district — a state that settles stops asking to be looked at. It
|
||||
pulses now for as long as the move is up, with a ring and a glow. The reduced-motion fallback is loud
|
||||
in a different way rather than simply still, since motion is the whole point here.
|
||||
|
||||
### The ceiling was not theoretical
|
||||
|
||||
*"At 10× — still a bit fast, but followable."* 10× was the top of the ladder, so the control's
|
||||
slowest setting was not slow enough for the person using it. `PACE_LEVELS` now runs to 20 and
|
||||
`MAX_PACE` with it. A control whose limit is reached in ordinary use has the wrong limit, not the
|
||||
right one held firmly.
|
||||
|
||||
---
|
||||
|
||||
## 0.8.0.5 — 2026-09-10
|
||||
|
||||
**Somewhere to look.** Jesse, playing v0.8.0.4 at 10×: *"many operations still occurred too fast for
|
||||
me to see."* At 10× an action holds the screen for seven seconds, so this was never about duration —
|
||||
it was that a bot drawing a card changes one number in a panel nobody is watching, and the board sits
|
||||
unchanged for those seven seconds. **Raising the dwell was the wrong lever, and it had been pulled
|
||||
three times.** His diagnosis was the right one: mark WHERE, not longer.
|
||||
|
||||
### The Home Office deck was never drawn
|
||||
|
||||
`f.deck` has carried the face-down count since the Frame existed and **nothing in `src/web/` read
|
||||
it** — the exact display gap `test/display-gaps.test.ts` was written to sweep for, surviving in the
|
||||
one panel that draws every other pile. It is a tile now, first in the row, because that is the order
|
||||
a card travels: out of the deck, into a hand, then onto a Department or the Salvage Yard. Face down,
|
||||
so its card slot says so rather than naming one — not knowing what is on top is the point of the
|
||||
pile.
|
||||
|
||||
### What lights, and why that is exactly what is public
|
||||
|
||||
The piles a move touched are now lit for as long as that move is on screen. **Derived, never sent**:
|
||||
the client already holds the frame before a step and the frame after it, so `changedPiles()` is a
|
||||
diff. Nothing is added to the protocol, nothing can drift out of step with the projection, and the
|
||||
0.8.1 seatless board gets it for free.
|
||||
|
||||
Measured across four seeds rather than reasoned about, and pinned by a test that requires each case
|
||||
to have actually occurred rather than passing on whichever the bot happened to play:
|
||||
|
||||
| action | lights | why that is public |
|
||||
| --- | --- | --- |
|
||||
| `draw.fromHomeOffice` | the deck | the count, never the card — a blind draw stays the drawer's |
|
||||
| `draw.fromDepartment` | that Department, and the deck when it refills | the pile is face up, so the card taken is public |
|
||||
| `card.discard` | that Department | face up, and which pile it went on is the point |
|
||||
| `card.play` | the Salvage Yard | where a played card that did not stay on the board lands |
|
||||
| switching, new trains | nothing here | they move the board, which the district panel already follows |
|
||||
| `*.end`, `localOps.choose` | nothing | no card moved |
|
||||
|
||||
**It is a state, not a flash**, and that distinction is the whole reason it works. The timetable's
|
||||
existing `.tt-slot.fresh` animates for a fixed 1.5s — right for a die roll nobody is waiting on, and
|
||||
wrong here, because a step can hold for seven seconds and the animation would be long over before
|
||||
the pause it belongs to. A brief flash-in marks the moment; the lit border and background stay for
|
||||
exactly as long as the step is up.
|
||||
|
||||
**Not for your own moves.** You drew that card — the same rule that already gives your own steps no
|
||||
dwell.
|
||||
|
||||
---
|
||||
|
||||
## 0.8.0.4 — 2026-09-09
|
||||
|
||||
**Housekeeping: the test server's name is out of the ten places this session put it.**
|
||||
|
||||
Both of this project's repositories allow anonymous clone — checked, not assumed: `info/refs` for
|
||||
`git-upload-pack` answers 200 for `station-master` and for `station-master-startos` alike, while
|
||||
`git-receive-pack` answers 401. So everything committed here is public, and the standing rule is that
|
||||
tracked files carry placeholders rather than real hosts.
|
||||
|
||||
Ten mentions added while building v0.8.0 are now "the test server" or "the target hardware":
|
||||
`CHANGELOG.md`, `docs/plans/jitsi-common-board.md` (three identical deferral banners), `sim/pacing.ts`,
|
||||
`test/pacing.test.ts` and `test/step-queue.test.ts`. Prose and comments only — no behaviour, and the
|
||||
quotes they carry are unchanged, because what a player said about bot pacing is the part worth
|
||||
keeping.
|
||||
|
||||
**What is deliberately left, and why it is not an oversight:**
|
||||
|
||||
- **Nineteen older mentions**, in entries about v0.7.5, v0.7.6 and v0.7.8 and in `TODO.md`. Rewriting
|
||||
a changelog after the fact makes the record less true, and these describe verification that
|
||||
genuinely happened on that machine.
|
||||
- **`scripts/deploy-web.ts` is FUNCTIONAL, not prose.** It carries the host as the default for
|
||||
`FB_URL`, so a placeholder there would break the deploy for the person the default exists to serve.
|
||||
Same for the public address it publishes to. If those should move to required environment variables
|
||||
with no default, that is a change to how deploying works and wants deciding on its own rather than
|
||||
being smuggled in beside a comment sweep.
|
||||
|
||||
---
|
||||
|
||||
## 0.8.0.3 — 2026-09-09
|
||||
|
||||
Three things from playing v0.8.0.2, all of them about the row rather than the mechanism.
|
||||
|
||||
### Skip was at the wrong end of the row
|
||||
|
||||
Jesse: *"the skip button should be on the far left, in front of where it says [the count], so it's
|
||||
always close to where people are looking."* It was on the far right, and a player's eye is on the
|
||||
countdown. Moved.
|
||||
|
||||
### The caption said what, but never who
|
||||
|
||||
*"I saw 2 behind, 1 behind, and then it was caught up, but it didn't tell me what the actual action
|
||||
was, like who I was waiting on or what they were doing. I knew I was behind, but I wasn't sure what I
|
||||
was supposed to be looking for."*
|
||||
|
||||
The caption was there. It was the wrong half of the sentence. **Measured over 40 turns of a real
|
||||
3-seat game, half the waiting is automatic phases** — 21.0s of phases against 21.7s of other players —
|
||||
and a phase narrates as "Mainline", which is accurate and no answer at all to "who am I waiting on".
|
||||
A phase now introduces itself: **"The Division: ▸ Mainline phase"**. A player's move already carries
|
||||
its name from `record()`, so it is left alone rather than stuttering it twice.
|
||||
|
||||
**And the row was hiding a step early.** It was shown only while `behind > 0` — which goes false the
|
||||
moment the LAST step of a burst goes up, so the one step a player was most likely to be reading about
|
||||
lost its caption. It now stays up while the queue is still showing something, and reads "catching up"
|
||||
once nothing is queued behind.
|
||||
|
||||
### The speed control was stretching the clock, not just the other players
|
||||
|
||||
*"After my turn, when I actually execute my turn, I'm still subject to that same delay before it
|
||||
moves on. That makes no sense. Since I've just done my turn, I don't need to wait after it."*
|
||||
|
||||
He was right, and it was not his move being replayed — own moves have cost nothing since v0.8.0.1. It
|
||||
was the automatic phases behind it, which were scaling with `pace` along with everything else. At 5×
|
||||
that put **105 seconds of clock-ticking** into the game, all of it after a player's own move and none
|
||||
of it anything to watch.
|
||||
|
||||
**`pace` now scales a player's move and leaves a phase at its tabled beat.** The control is labelled
|
||||
as how long another player's move is held, and that is now what it does. A phase still gets its beat
|
||||
(TODO #18) and still vanishes entirely at `pace = 0`, because off has to mean off.
|
||||
|
||||
---
|
||||
|
||||
## 0.8.0.2 — 2026-09-09
|
||||
|
||||
Two things found by playing v0.8.0.1 on the test server, neither of them in the mechanism itself.
|
||||
|
||||
### `?pace=` never worked, and a whole game was played at the wrong speed
|
||||
|
||||
Jesse: *"I'm playing at pace = 7, and the bots are still moving too fast for me to follow."* At 7×
|
||||
a switching move holds for seven seconds, so that could not be calibration — and it was not. **He was
|
||||
at 1× the entire time.**
|
||||
|
||||
`index.html`'s two doors are `./play.html?lobby` and `./play.html?solitaire`. Arriving through the
|
||||
splash therefore **replaces** the query string, and `location.search` on the play page is `?lobby` —
|
||||
so `PACE_OVERRIDE` was null and it fell back to the stored setting of 1. v0.8.0 shipped `?pace=` as
|
||||
the only way to change speed and the game's own front door destroyed it. Verified rather than
|
||||
assumed: the queue at pace 7 holds a bot's turn for 32.9s with the bot's district up for 24.5s, so
|
||||
the mechanism was right and the value never arrived.
|
||||
|
||||
Fixed twice over, because one of them is the durable answer:
|
||||
|
||||
- **A speed control on the play screen**, beside zoom — `− 1× +`, persisted per viewer, reading
|
||||
through to the queue on the very next move. `PACE_LEVELS` is `0, 0.5, 1, 2, 3, 5, 7, 10`: off is
|
||||
the first rung (TODO #18's "a player who has seen it a hundred times will want it off") and the
|
||||
ladder reaches the speeds people actually reach for. At the top, a six-move switching turn takes a
|
||||
full minute to watch.
|
||||
- **The doors now carry `pace` through**, so the URL lever is honest for handing two playtesters
|
||||
different speeds — the only thing it was ever for. When one is present the control says
|
||||
`7× (URL)` and disables itself rather than showing buttons that do nothing.
|
||||
|
||||
`PACE_LEVELS` lives in `sim/pacing.ts` with `DWELL` and `MAX_PACE`, not in `main.ts` — the whole
|
||||
tuning surface in one file, and testable, which a constant inside the page entry point is not.
|
||||
|
||||
**The committed default is unchanged at 1×.** What it should be is a question for a game played at a
|
||||
speed that actually took effect.
|
||||
|
||||
### "0 today, 2 in all" — the Day-end dialog contradicted itself Jesse, at the end of a Day 1 with
|
||||
two collisions in it: *"It shows a total of two collisions, but zero today. Since we just finished day
|
||||
one, that does seem to be a contradiction."* Unrelated to v0.8.0 — this has been wrong since the
|
||||
dialog was built for Gitea#10, and nobody had played a Day with a collision in it and then read the
|
||||
summary.
|
||||
|
||||
#### One line of ordering
|
||||
|
||||
`advance.ts`, at the rollover:
|
||||
|
||||
```ts
|
||||
s.clock.day += 1;
|
||||
s.collisionsToday = 0;
|
||||
```
|
||||
|
||||
And `noteDayEnd()` fires when `f.day` goes UP — so the dialog reporting the Day that just finished is
|
||||
drawn from the very frame in which that Day's count was zeroed. It printed the *new* Day's zero beside
|
||||
a running total that could not possibly agree with it. Reproduced on four of five seeds before
|
||||
touching anything: Day 1 ended with `today=3 total=3`, and the dialog read `today=0 total=3`.
|
||||
|
||||
**Not derivable on the client, which is why the fix is in the engine.** A Day turns over inside the
|
||||
phases that run themselves, so in multiplayer the push announcing the new Day is the same push that
|
||||
carries the reset — a client may never see the ended Day's final count to remember it. So
|
||||
`collisionsPrevDay` is captured in state at the rollover, immediately before the reset, and rides on
|
||||
the frame like the other two counts.
|
||||
|
||||
#### And "today" was the wrong word anyway
|
||||
|
||||
Even with the right number, a dialog headed "Day 1 has ended" should not say "today" — by then
|
||||
"today" is Day 2. It now names the Day: **"Collisions: 2 on Day 1, 2 in all."** The end-of-game
|
||||
results screen passes no Day and keeps "today", where the Day has not turned over and the word is
|
||||
accurate.
|
||||
|
||||
`test/redaction.test.ts`'s allow-list did its job on the way through: adding a public property failed
|
||||
the suite until it was declared out loud.
|
||||
|
||||
---
|
||||
|
||||
## 0.8.0.1 — 2026-09-09
|
||||
|
||||
**Bot play was way too fast.** v0.8.0 was installed on the test server and played within the hour;
|
||||
Jesse: *"I briefly saw that it was the bot's office area then their turn was done and it pointed back
|
||||
to my office area"*, and the countdown row appeared "very briefly". Everything else looked right —
|
||||
the bots were visibly doing things — so this is calibration and one real bug, not a redesign.
|
||||
|
||||
### The bug: the last step of a burst never got its moment
|
||||
|
||||
`busy()` was `pending.length > 0`. So the instant the FINAL step of a burst was shown, the queue
|
||||
reported itself idle — the animation loop stopped and, because the district panel follows `busy()`,
|
||||
it snapped back to the viewer's own board without that step ever being looked at. The countdown row
|
||||
went with it. `busy()` is now `pending.length > 0 || dueAt !== null`: there is more to come, **or**
|
||||
what is on screen has not had its moment yet.
|
||||
|
||||
### The calibration: 250ms was invented, and it was wrong
|
||||
|
||||
Jesse's instruction had been "start at 1s and tune down". That was applied to switching and then a
|
||||
250ms `action` tier was made up beside it, which held for the case the design was measured against —
|
||||
a switching burst — and failed the common one. **Switching is not legal until there is track down**,
|
||||
so an early-game bot turn contains none of it. Measured from a real 3-seat game, one bot turn was:
|
||||
|
||||
```
|
||||
localOps.choose 0ms · draw.fromHomeOffice 250ms · card.play 250ms
|
||||
draw.end 0ms · localOps.choose 0ms · freightAgent.stockOutbound 250ms
|
||||
```
|
||||
|
||||
**750ms for a whole turn.** `action` is now 700ms, which puts that same turn at 4.7s.
|
||||
|
||||
**And `localOps.choose` was the worst of it.** It was classed as bookkeeping, at zero — but it is the
|
||||
line reading *"Player Bot 1 chose to SWITCH — six Moves to shunt cars around the yard"*: the heading
|
||||
for everything that follows. A bot's turn began with no indication of what it was about to do. It is
|
||||
an announcement, and it is now in `action`.
|
||||
|
||||
### The viewer's own moves cost nothing
|
||||
|
||||
Raising `action` exposed a waste: your own click was being held for 700ms before the bots' turn
|
||||
started animating. A seated player's own board is drawn from their authoritative `Frame`, never from
|
||||
the queue, so replaying their own move shows them nothing and delays the thing they wanted to watch.
|
||||
Own steps are still applied — the delta chain runs through them — but at zero dwell. Automatic phases
|
||||
have no player and are unaffected, which is what keeps #18 working in solitaire, where every intent
|
||||
is the viewer's own.
|
||||
|
||||
### Faster and slower, without a rebuild
|
||||
|
||||
`pace` multipliers above 1 are supported and expected — Jesse asked for 2 and 3 — bounded by a new
|
||||
`MAX_PACE` of 10 so that `?pace=300` from somebody meaning 3.00 cannot look like a frozen board.
|
||||
Every tier scales by the same factor, so **a switching move outlasts an ordinary action at 0.5× and
|
||||
at 3× alike**: the relative weighting is the design, and the multiplier is only how fast it runs.
|
||||
|
||||
Whole-game animation is now ~5.7 minutes across a 6-day game.
|
||||
|
||||
---
|
||||
|
||||
## 0.8.0 — 2026-09-09
|
||||
|
||||
**Watching the table.** TODO #13, #15 and #18, which is Gitea#20 steps 2-4 pointed at a seated
|
||||
player's own screen. Jesse, 2026-08-29: *"It's not fun to do my turn and have magic happen in the
|
||||
background and then have to figure out what others did."* And 2026-09-09, on what he most wants to
|
||||
see: *"I definitely want to watch other players struggle with the switching exercises … I don't
|
||||
think reading the switching in the log will be anywhere nearly as interesting as watching the trains
|
||||
actually move on the board."*
|
||||
|
||||
The release was scoped in conversation: **0.8.0 is this, 0.8.1 is the seatless board page, 0.9.0 is
|
||||
the Jitsi publisher** — *"13 is the key. Watching on a TV is the bonus."* The design is
|
||||
`docs/plans/jitsi-common-board.md` § v0.8.0.
|
||||
|
||||
### The correction that set the scope
|
||||
|
||||
`Frame.cells` is ONE district — the viewer's own, built from `areaOf(s, viewer)`. So a step stream
|
||||
alone does not answer #13: the data would arrive with nowhere to be drawn. **Rendering a district
|
||||
you do not own is the feature**, not part of the seatless page it had been filed under. Nothing
|
||||
needed to change in `officeSvg` to do it — it takes board data and has never wanted a private
|
||||
viewer, which is why `PublicDistrict` renders as-is.
|
||||
|
||||
### One hook, not two, and replay inert for free
|
||||
|
||||
The design anticipated wiring a collector into `GameSession.intent()` and `driveBots()` separately,
|
||||
with solitaire doing its own thing. It needs neither: `src/server/session.ts` imports `submit` from
|
||||
`src/web/game.ts`, so solitaire, live multiplayer and every bot turn already funnel through one
|
||||
function. That is also what makes this a special case of multiplayer rather than a second
|
||||
implementation.
|
||||
|
||||
And `fromSave`/`fromMultiplayerSave` rebuild a game with `applyIntent` + `record` + `drain` rather
|
||||
than `submit`, so a resumed server does not re-emit a whole game as steps. The plan expected that to
|
||||
need a guard. It needs none — but the property is load-bearing rather than lucky, so it is pinned by
|
||||
test.
|
||||
|
||||
### Pacing, decided by measurement
|
||||
|
||||
The obvious scheme is a time budget divided by the queue length. Measured against real games it does
|
||||
exactly the wrong thing: 44 of a 307-intent game are `draw.end` and 60 are `loadUnload.end`, while
|
||||
the thing worth watching is rare and clustered — two of the three published replays contain no
|
||||
`switch.move` at all, and the third has bursts of **14, 6, 6 and 6**. Six is the engine's own cap per
|
||||
crew, which `trayMoved` says out loud ("N of 6 Moves left"). A uniform budget spends the player's
|
||||
attention on bookkeeping and rushes the switching.
|
||||
|
||||
So dwell is assigned **by kind**: switching 1000ms (Jesse: *"start at 1s and tune down"*), an
|
||||
ordinary action 250ms, a phase 600ms, bookkeeping zero. Three tuning levels, because the committed
|
||||
table needs a web rebuild and in the `.s9pk` that is a release: the table, a per-viewer `pace`
|
||||
multiplier in `Settings` where **0 turns it off**, and a `?pace=` URL parameter for handing two
|
||||
playtesters different speeds. Deliberately **not** in game-creation settings — dwell is presentation,
|
||||
not a rule, and a `GameConfig` rides along in saves and replays.
|
||||
|
||||
**Cost, measured:** about **4.4 minutes of animation across a whole 6-day game**, of which phases are
|
||||
now the largest slice and therefore the first dial to turn.
|
||||
|
||||
### TODO #18 needed a stepped pump, not a delay
|
||||
|
||||
`pump()` runs every automatic phase between one click and the next and `drain()` records the whole
|
||||
batch, so New Train, the Mainline and the shift change were never drawn at all. Folding them into the
|
||||
triggering intent's step reproduced exactly that. `submit()` now steps `advance()` one call at a time
|
||||
and collects per phase; `drain()` is untouched, because replay, undo and `fromSave` all use it and
|
||||
the inertness above depends on their staying off that path.
|
||||
|
||||
**Two obvious rules for what earns a beat were both wrong, and both are now pinned by test.** "No
|
||||
narration, no dwell" looked right and silently killed #18 — a phase can move trains without saying
|
||||
anything. "Anything that changed the board" beat on every turn hand-off, and `submit()` steps
|
||||
`advance()` about 4.6 times per intent, which came to a quarter of an hour a game. The rule is that
|
||||
the **clock turning over** earns the beat.
|
||||
|
||||
### The counter, which is Jesse's design
|
||||
|
||||
*"If I saw the counter as I'm watching the board go 17, 16, 15 … and I got impatient, I could just
|
||||
click a button and have it skip all the rest."* One row rather than three additions — the countdown,
|
||||
#15's caption naming the action being shown, and Skip. It counts only the steps that will actually
|
||||
**dwell**: with bookkeeping at zero, a backlog of 17 where 12 are `*.end` would read "17", plummet to
|
||||
5 instantly and then crawl, which is not a countdown anyone can act on. Skip costs the animation and
|
||||
never the information — every line is already in the History panel.
|
||||
|
||||
This also settled the question the design had left open: an "it's your turn" that arrives while the
|
||||
board is still catching up is confusing, and showing the lag beats both alternatives (holding the
|
||||
turn indicator back, or saying nothing).
|
||||
|
||||
### Switching logged unattributed, and now names its train
|
||||
|
||||
`record()` attributes a line only when the event carries `player`. **`trayMoved`, `carsCoupled`,
|
||||
`carsDropped` and `consistSorted` were the only events in their class that did not** — so a switching
|
||||
turn read as an attributed bracket around anonymous contents: "Player Alice chose to switch / CREW
|
||||
moved (1,2) → (1,3) / Player Alice finished Local Operations". Fixed with the feature that reads
|
||||
those lines rather than filed. Two texts were reworded to compose with the prefix, because
|
||||
`uncapitalise` deliberately protects acronyms and "Player Alice CREW moved" is what it would
|
||||
otherwise have produced. On Jesse's ask the move now names its train — "moved Train 3 (1,2) → (1,3)"
|
||||
— using the existing `trainName`, since a second way of naming a train is the drift this codebase
|
||||
avoids.
|
||||
|
||||
Saves are unaffected: a save is a seed and a list of intents, and events are derived.
|
||||
|
||||
### The delta had to become a true partial
|
||||
|
||||
Steps carry a `PublicFrame` delta. The first version spread `...next` and nulled only the board
|
||||
fields, so every step shipped all 35 top-level properties even when the only change was whose turn it
|
||||
was. Once #18 gave phases their own steps most steps became exactly that, and a full game cost
|
||||
**19.4 MB, of which 16.7 MB was silent steps at ~11 KB each**. As a true partial — only changed
|
||||
fields, only changed districts — the same game is **8.7 MB**. Districts are keyed by seat rather than
|
||||
compared as one array, which alone saves 22%: one intent changes one district, and a whole-array
|
||||
compare resends every other player's board every step.
|
||||
|
||||
### The redaction net grew to cover the steps, and grew two exemptions
|
||||
|
||||
The steps are folded into `everythingSeatSees`, so every existing case covers them — the blind draw,
|
||||
the pending decision, Employee Rotation before and after the seating moves, the reconnect, the
|
||||
played-out game. Doing that surfaced two false positives in the name-based heuristic, **neither
|
||||
caused by this feature**, and the distinction they forced is worth keeping: **a card NAME is
|
||||
circumstantial, a card ID is proof.** Ids are searched everywhere. Names are not searched in two
|
||||
places entitled to carry them — lines naming a **face-up pile** (§2.6: a Department is public, so
|
||||
"Ann discarded Train 6 face-up on Department 3" is the record working, and it stays in the log after
|
||||
she takes it back), and the **accumulated step frames**, which record what was public over time
|
||||
rather than the position now.
|
||||
|
||||
The harness was also passing `g.log` into `snapshot()` for the Frame's own lines, which **production
|
||||
has not done since #97**; now `[]`, matching `frameFor()`.
|
||||
|
||||
**Verified by injecting the v0.7.9.2 blind-draw leak and confirming the net still fails** — both the
|
||||
dedicated test and, independently, the new step coverage. A relaxed safety test that has not been
|
||||
shown to still bite is not a safety test.
|
||||
|
||||
### What is not verified
|
||||
|
||||
The mechanism is proven end to end **server-side**: a real server, a real 3-seat game with two bots,
|
||||
and 28 steps read off a live SSE stream with dense sequence numbers and a 13-step bot burst intact.
|
||||
The page is proven not to throw — `drainIntoQueue`, `renderWatching` and `watchedDistrict` all run
|
||||
under the existing DOM-stub tests. **Nobody has watched it in a browser.** The district switching to a
|
||||
bot's board, the row appearing, and Skip are unexercised, because the stub has no
|
||||
`requestAnimationFrame` and the page degrades to un-animated without one.
|
||||
|
||||
---
|
||||
|
||||
## 0.7.9.8 — 2026-09-07
|
||||
|
||||
Housekeeping before v0.8.0 — the answer to "anything else that should be looked at first", which
|
||||
|
||||
@@ -35,8 +35,19 @@ deliberately no longer names one: it went stale for six releases.
|
||||
reload; anybody may leave and the host may clear a chair; and the four transient signals that make
|
||||
a game feel alive — sound, the timetable flash, an announcement, the badge on the card you just
|
||||
drew — reach a remote client, which they did not before v0.7.0. What is still open is in `TODO.md`
|
||||
under Multiplayer — chiefly that **a player cannot see what the others did**, and that a lost
|
||||
under Multiplayer — chiefly that a lost
|
||||
session token still locks someone out of a running game from a genuinely fresh browser.
|
||||
- **Watching the table — v0.8.0.** Every accepted move, and every automatic phase that does
|
||||
anything, becomes an ordered **presentation step**: the board replays other people's turns instead
|
||||
of arriving already rearranged. This is what closes "a player cannot see what the others did",
|
||||
which stood open through v0.7.x. A bot's whole switching turn used to land in one push, because
|
||||
`driveBots()` plays it out before the push goes back; now it arrives as a run of steps, the
|
||||
district panel follows whoever is acting, and a `[N behind] … [Skip]` row says how far the board is
|
||||
from the game. Dwell is assigned **by kind** — a switching move holds the screen, turn bookkeeping
|
||||
costs nothing — and is tunable per viewer without a rebuild. Solitaire runs the same path, which is
|
||||
where its automatic phases finally get a visible beat.
|
||||
**Not yet checked in a browser:** the mechanism is proven server-side against a live SSE stream and
|
||||
the page is proven not to throw, but nobody has watched a bot switch on screen.
|
||||
- **Not built** — the opponent-directed cards (the Action and Space-use categories, held out of every
|
||||
deck until they have an implementation, along with the defensive cards whose only purpose is to
|
||||
answer them), and real audio. No screen offers a control for the opponent cards any more: the
|
||||
|
||||
@@ -128,32 +128,72 @@ specific paths below have not been exercised at a table. **More testing is plann
|
||||
|
||||
## The common board, and watching play happen — Gitea#20
|
||||
|
||||
**This is v0.8.0.** One shared, seatless display of the public game, usable on a TV or in OBS on its
|
||||
own and publishable into the table's Jitsi meeting. The plan is `docs/plans/jitsi-common-board.md`,
|
||||
seven steps, of which 1-4 are the useful release and 5-7 are the Jitsi publisher.
|
||||
One shared, seatless display of the public game, usable on a TV or in OBS on its own and publishable
|
||||
into the table's Jitsi meeting. The plan is `docs/plans/jitsi-common-board.md`, seven steps.
|
||||
|
||||
**THE RELEASE SPLIT, settled with Jesse 2026-09-09. Jesse: "13 is the key. Watching on a TV is the
|
||||
bonus."**
|
||||
|
||||
- **v0.8.0 — #13, #15 and #18: the watchable table.** The step collector, steps on the `Session`
|
||||
interface, **foreign-district rendering**, the client animation queue, pacing by kind, and the
|
||||
behind-counter. **The design is the plan's § v0.8.0**, which supersedes the parts of steps 2-4 it
|
||||
covers. Needs no HTTP work at all.
|
||||
- **v0.8.1 — the seatless board page.** `display.json`, `viewToken`, `/api/display/stream`,
|
||||
`/display.html`, an all-districts layout: most of step 2 and step 3 without its canvas. Cheap once
|
||||
0.8.0 lands, and nothing in it moves #13 forward.
|
||||
- **v0.9.0 — the Jitsi publisher.** Steps 5-7 plus step 3's canvas pipeline. Held off deliberately:
|
||||
it needs Chromium in the image (several hundred MB onto a 63 MB `.s9pk`) and measurement on
|
||||
`phoenix.local`, and the only self-hosted Jitsi available needs an authenticated moderator to open
|
||||
a room, so "waiting for moderator" is the ordinary path here rather than an edge case.
|
||||
|
||||
**The correction that set that split:** `Frame.cells` is ONE district — the viewer's own
|
||||
(`view.ts:510`, from `areaOf(s, viewer)`), exactly as **Reference · #13** already said. So a step
|
||||
stream alone does not answer #13; the data would arrive with nowhere to be drawn. Rendering a
|
||||
district you do not own is the core of 0.8.0, not part of the seatless page. Two other decisions
|
||||
taken with it: **no WebSocket and no new runtime dependency** (SSE down + POST up, the pattern
|
||||
`server/http.ts` already uses), and `protocolVersion` on the wire in 0.8.0.
|
||||
|
||||
**Step 1 is BUILT** — v0.7.9.2 through v0.7.9.5 (#91, #92, #95, #97), with one item struck off
|
||||
rather than implemented (#103). **The plan was reconciled against the code in v0.7.9.8** and now says
|
||||
which of its "current code findings" are history: it had drifted badly enough to send the next reader
|
||||
fixing things twice. Steps 2-7 were never implemented and their findings have NOT been re-verified —
|
||||
check each before building on it. See **Reference · #103**. Items that look
|
||||
like screen polish live here because they need step 4's ordered presentation mechanism and nothing
|
||||
cheaper.
|
||||
rather than implemented (#103). **The plan was reconciled against the code in v0.7.9.8** and again
|
||||
on 2026-09-09, and now says which of its "current code findings" are history: it had drifted badly
|
||||
enough to send the next reader fixing things twice. Steps 2-7 were never implemented; step 4's
|
||||
findings were re-verified 2026-09-09 and steps 5-7's were NOT — check each before building on it.
|
||||
See **Done · 103**. Items that look like screen polish live here because they need step 4's ordered
|
||||
presentation mechanism and nothing cheaper.
|
||||
|
||||
**The design is settled — the plan's § v0.8.0 is the authority.** In outline: public steps animate
|
||||
the board while the existing private Push supplies hand, menu and objective (so there is no new
|
||||
redaction surface); the collector is a shared `sim/` module both `LocalSession.submit()` and
|
||||
`GameSession.submit()` call, so solitaire and multiplayer run one code path; dwell is assigned **by
|
||||
kind** with switching protected at 1s and bookkeeping at zero; and a `[N behind] … [Skip]` row shows
|
||||
the lag, carries #15's caption, and never blocks input. Two measurements that decided it: a
|
||||
switching turn runs to the engine's cap of **6 moves** (bursts of 14, 6, 6, 6 in `seed-1917398`),
|
||||
and dwell-by-kind costs ~40s of animation across a 60-stage game against 3.6 minutes for a flat
|
||||
700ms.
|
||||
|
||||
- [ ] **#13** — I cannot see what the other players did — bots included. **Settled 2026-08-29 as the
|
||||
harder reading**: not log legibility but the ordered, per-action presentation of everyone else's
|
||||
turns. Jesse: "It's not fun to do my turn and have magic happen in the background." This is
|
||||
Gitea#20 step 4 pointed at a player's own screen. See **Reference · #13**.
|
||||
Gitea#20 step 4 pointed at a player's own screen. **DESIGNED 2026-09-09 — the plan's § v0.8.0.**
|
||||
The answer is foreign-district rendering with focus following the actor; without it a step
|
||||
stream has nowhere to draw, because `Frame.cells` is the viewer's district alone. See
|
||||
**Reference · #13**.
|
||||
|
||||
- [ ] **#15** — A "most recent action" line under the status block. The text already exists and is
|
||||
already correct — this is placement, not content. **Decide with #13**: in solitaire "most
|
||||
recent" is the right unit; in multiplayer what you missed is everything that happened while you
|
||||
were WAITING. See **Reference · #15**.
|
||||
were WAITING. **DESIGNED 2026-09-09 — it is the caption in the `[N behind] … [Skip]` row, not a
|
||||
separate line.** The queue IS "everything that happened while you were waiting", which is the
|
||||
unit this entry could not choose. See **Reference · #15**.
|
||||
|
||||
- [ ] **#18** — Give every phase a visible beat. Not a timing problem — `pump` runs every automatic
|
||||
phase before the page renders once, so they are never drawn at all. **A `sleep` fixes nothing;
|
||||
it needs the async stepped pump that Gitea#20 step 4 specifies**, which is why it lives here
|
||||
rather than under The screen. See **Reference · #18**.
|
||||
rather than under The screen. **DESIGNED 2026-09-09 — it is a dwell setting on the shared queue,
|
||||
not a feature.** Phases where nothing happened dwell at ZERO (Jesse: "if nothing happens during
|
||||
a phase then we shouldn't lose time to it"); a flat second per phase is rejected on the same
|
||||
arithmetic this entry already worked out. Solitaire gets it through `LocalSession`, the same
|
||||
path multiplayer gets #13 through. See **Reference · #18**.
|
||||
|
||||
- [ ] **#75** — Let the game join a call and talk to the table — the chat, audio and nudge half of the
|
||||
idea Gitea#20 took the visual half of. Long-term. See **Reference · #75**.
|
||||
@@ -1761,7 +1801,7 @@ where it belongs, and it is still open.
|
||||
Closed items, kept because several are the only record of a ruling or a lesson. Newest first within
|
||||
each group.
|
||||
|
||||
### Shipped through v0.7.9.4, from the queue
|
||||
### Shipped through v0.7.9.8, from the queue
|
||||
|
||||
Closed items, newest first. Kept because several of them are the only record of a ruling or a lesson;
|
||||
the numbers stay so cross-references above and below still resolve.
|
||||
@@ -1996,11 +2036,13 @@ the numbers stay so cross-references above and below still resolve.
|
||||
`# fail 0`. `pretest` is `tsc --noEmit && node scripts/build-web.ts` now, and the same planted
|
||||
error exits 1 with the tests never running.
|
||||
|
||||
**Why it mattered THIS week rather than generally.** v0.8.0 is steps 2-7 of the common board —
|
||||
a display stream, credentials, persistence, and a Chromium supervisor — which is almost
|
||||
entirely `src/server/` and is exactly the half the test command could not see. Found while
|
||||
answering "anything else before 0.8.0", which is the only reason it was found at all: nothing
|
||||
about a green suite would ever have said so.
|
||||
**Why it mattered THIS week rather than generally.** The next release is steps 2-4 of the
|
||||
common board — a display stream, credentials, persistence and the display-step collector, which
|
||||
is almost entirely `src/server/` and is exactly the half the test command could not see. (The
|
||||
Chromium supervisor was in this list when the entry was written; steps 5-7 became v0.9.0 on
|
||||
2026-09-09, and the point stands without it.) Found while answering "anything else before
|
||||
0.8.0", which is the only reason it was found at all: nothing about a green suite would ever
|
||||
have said so.
|
||||
|
||||
103. ~~**The common-board plan had drifted from the code it is the source for.**~~ — done 2026-09-07
|
||||
in v0.7.9.8. `docs/plans/jitsi-common-board.md` was written 2026-08-27, still said "No
|
||||
|
||||
@@ -1,11 +1,53 @@
|
||||
# Station Master Jitsi Common Board Implementation Plan
|
||||
|
||||
**Status (2026-09-07):** **STEP 1 IS BUILT AND SHIPPED. Steps 2-7 are unimplemented.**
|
||||
**Status (2026-09-09):** **STEP 1 IS BUILT AND SHIPPED. Steps 2-7 are unimplemented.**
|
||||
|
||||
Step 1 landed across four releases rather than one — v0.7.9.2 (the two narration leaks), v0.7.9.4
|
||||
(the projection helpers and the redaction net), v0.7.9.5 (the narration path), and v0.7.9.8 (this
|
||||
reconciliation). One of its items is struck off rather than built; see § Public game projection.
|
||||
|
||||
## THE RELEASE SPLIT — read this before picking up any step
|
||||
|
||||
Settled with Jesse 2026-09-09. The document below was written as one seven-step delivery and its
|
||||
ordering still reads that way, so this section and § v0.8.0 are the authority on what belongs where.
|
||||
|
||||
| Release | What | Why here |
|
||||
| --- | --- | --- |
|
||||
| **v0.8.0** | **TODO #13/#15/#18 — the watchable table.** The step collector, steps on the `Session` interface, foreign-district rendering, the client animation queue, pacing, and the behind-counter. § v0.8.0 is the design. | #13 is the point. *"Watching on a TV is the bonus"* — Jesse, 2026-09-09. |
|
||||
| **v0.8.1** | **The seatless board page.** `display.json`, `viewToken`, `/api/display/stream`, `/display.html`, an all-districts layout. Most of step 2, and step 3 without its canvas. | Cheap once 0.8.0's foreign-district rendering exists; nothing in it moves #13 forward. |
|
||||
| **v0.9.0** | **The Jitsi publisher.** Steps 5-7, plus step 3's canvas capture pipeline. | Needs Chromium in the image and hardware measurement. Held off deliberately. |
|
||||
|
||||
**What 0.8.0 is FOR, in Jesse's words (TODO #13, 2026-08-29):** *"It's not fun to do my turn and have
|
||||
magic happen in the background and then have to figure out what others did."* And 2026-09-09, on what
|
||||
he most wants to watch: *"I definitely want to watch other players struggle with the switching
|
||||
exercises … I don't think reading the switching in the log will be anywhere nearly as interesting as
|
||||
watching the trains actually move on the board."*
|
||||
|
||||
### Decisions taken 2026-09-09 that the text below has NOT been rewritten around
|
||||
|
||||
- **`Frame.cells` is ONE district — the viewer's own** (`view.ts:510`, from `areaOf(s, viewer)`). So a
|
||||
step stream alone does not answer #13: the data would arrive and have nowhere to be drawn.
|
||||
**Rendering a district you do not own is the core of 0.8.0, not part of the seatless page.** This
|
||||
is the correction that set the split above; it was mis-assigned until 2026-09-09.
|
||||
- **No WebSocket, and no new runtime dependency.** The control channel exists only to join the
|
||||
supervisor to the headless agent — a handful of messages per publisher lifetime, on loopback.
|
||||
`package.json` has no `dependencies` key and the wrapper's runtime image copies only
|
||||
`package.json` and `src/` with no `node_modules`, so adding `ws` changes the deployment model
|
||||
rather than adding a dependency. Use SSE down + POST up, the pattern `src/server/http.ts` already
|
||||
implements for players (`/api/stream` + `/api/intent`). The proven Jitsi code ports either way:
|
||||
`engine/communications/CommunicationsClient.ts` in the sibling repo references the control layer
|
||||
in two comments and nothing else. **0.9.0 work.**
|
||||
- **`protocolVersion` is added in 0.8.0.** See § Public game projection.
|
||||
- **0.8.0 needs no HTTP work at all.** The `startServer()` refactor and the project's first HTTP test
|
||||
harness existed to test `/api/display/stream`, which is now 0.8.1. `Push.steps` is built in
|
||||
`session.ts`, and `broadcastGame()` forwards whatever push it is handed, so `http.ts` does not
|
||||
change and the tests land in the existing `test/server/session.test.ts`.
|
||||
- **Solitaire is a special case of multiplayer, not a second implementation** — Jesse's stated design
|
||||
direction, to minimize rule and implementation drift. `src/web/session.ts` already draws that seam:
|
||||
*"the page … does not care whether the rules are being applied a function call away or across a
|
||||
network."* Every mechanism below hangs off `Session`, with `LocalSession` and `RemoteSession` both
|
||||
feeding it. Solitaire therefore gets #18 through the same code path multiplayer gets #13 through.
|
||||
|
||||
**This document has drifted from the code and is no longer the authority on what exists.** It was
|
||||
written on 2026-08-27 against the code of that date, and the "Current code findings" under each step
|
||||
describe faults that were then real — several are now fixed, and reading them as present tense will
|
||||
@@ -13,6 +55,262 @@ send you to fix things twice. Where a step is marked built, `src/sim/view.ts`, `
|
||||
tests named in TODO.md are the authority. Steps 2-7 were never implemented and their findings have
|
||||
NOT been re-verified against the current code; check each before building on it.
|
||||
|
||||
## v0.8.0 — The watchable table
|
||||
|
||||
**This section supersedes the parts of steps 2-4 it covers.** Where it and a step below disagree,
|
||||
this wins; the steps keep the material that is still 0.8.1/0.9.0 work. Designed with Jesse
|
||||
2026-09-09 in conversation; every measurement quoted was taken from the code and the published
|
||||
replays that day.
|
||||
|
||||
### The shape
|
||||
|
||||
One mechanism, four consumers, no branch between solitaire and multiplayer:
|
||||
|
||||
```
|
||||
submit() ──► collector (sim/) ──► DisplayStep
|
||||
│
|
||||
┌───────────────────┴───────────────────┐
|
||||
LocalSession.steps() Push.steps ──► RemoteSession.steps()
|
||||
└───────────────────┬───────────────────┘
|
||||
▼
|
||||
client animation queue (one impl)
|
||||
▼
|
||||
board render · caption · behind-counter · skip
|
||||
```
|
||||
|
||||
### 1. The collector
|
||||
|
||||
> **BUILT 2026-09-09** — `src/sim/display-step.ts`, `test/watchable.test.ts`. Two things below were
|
||||
> wrong in a way worth recording, because both made the job smaller.
|
||||
|
||||
**ONE HOOK, NOT TWO.** This section said to wire two call sites in `GameSession` and let
|
||||
`LocalSession` do its own thing. It does not need to: **`src/server/session.ts` imports `submit`
|
||||
from `src/web/game.ts`**, so solitaire, live multiplayer and every bot turn already funnel through
|
||||
one function. Collecting inside `submit()` covers all three, and *that* is what makes solitaire a
|
||||
special case of multiplayer here rather than a parallel implementation.
|
||||
|
||||
**REPLAY IS INERT FOR FREE.** `fromSave` and `fromMultiplayerSave` rebuild a game with `applyIntent`
|
||||
+ `record` + `drain` directly rather than through `submit`, so a resumed server does not re-emit the
|
||||
whole game as steps. No guard is needed. But the property is load-bearing rather than lucky — move a
|
||||
replay path onto `submit()` and it silently becomes the #97-class bug this section feared — so
|
||||
`test/watchable.test.ts` pins it.
|
||||
|
||||
- One step per accepted intent, **including** automatic work drained behind it. Never one step per
|
||||
`GameEvent` — an intent drains `pump()` work and the event list is not a complete reducer.
|
||||
- **Narration's high-water mark is taken inside `submit()`**, which brackets `record` and `drain` and
|
||||
is therefore the only place that knows what one intent said. Not `sentLines`: that is per-seat and
|
||||
is *mutated* by `linesSince()` as a side effect of building a push.
|
||||
- Capture the frame immediately; never retain a mutable `GameState` reference for later projection,
|
||||
or every retained reference resolves to the final state.
|
||||
- Accumulated on `Game` beside `log`, `cues` and `announced` and drained by `takeSteps()` the way
|
||||
`takeMoment()` drains the rest — the established convention for "the model accumulated something,
|
||||
the view takes it". `pushesForAll()` drains ONCE per broadcast, not per seat.
|
||||
|
||||
### 2. Delivery — on the `Session` interface
|
||||
|
||||
`Session` gains steps. `LocalSession` emits them from its own `submit()`; `RemoteSession` reads them
|
||||
off `Push.steps`. The page consumes one queue and cannot tell which it has.
|
||||
|
||||
`Push` gains `steps?: DisplayStep[]`, following the **`presence` precedent** — `Push.frame` is
|
||||
already optional, and presence went in as a field rather than a second SSE event type for the reason
|
||||
recorded at `http.ts:210`: *"one message shape for the client to parse."*
|
||||
|
||||
`http.ts` does not change. `broadcastGame()` forwards whatever push `session.ts` builds.
|
||||
|
||||
### 3. What a step carries, and what animates
|
||||
|
||||
**Public steps animate the board; the private `Frame` supplies hand, menu and objective.**
|
||||
|
||||
Your hand never changes because somebody else moved. What can change splits cleanly: revenue is
|
||||
already in `PublicFrame` (`players` carries it) so it animates; `menu` and `blocked` are recomputed
|
||||
and arrive with the final coalesced Push, as today. So a step carries a `PublicFrame` delta and
|
||||
**adds no new redaction surface** — it reuses the projection `test/redaction.test.ts` already guards.
|
||||
|
||||
Rejected: emitting N per-seat redacted `Frame`s per intent. It multiplies both the projection work
|
||||
and the redaction test surface, and buys nothing — a seated step would be a `Frame`, which is
|
||||
redacted per seat, so it could not reuse the public delta anyway.
|
||||
|
||||
**Delta the districts per seat, not as one array.** Measured 2026-09-09 over a 300-step 4-player
|
||||
game (`sim/public-delta.ts` now built, `test/public-delta.test.ts` pins reconstruction): a full
|
||||
frame every step is **20.4 KB/step, 6.0 MB** over the game; a `frame-delta`-style whole-array
|
||||
compare is **3.0 MB** (51%); keying by seat is **2.4 MB** (40%), saving a further **657 KB, 22%**
|
||||
over the whole-array form. `sim/frame-delta.ts` hardcodes
|
||||
`BOARD_KEYS = ['cells','facilities','division']` against `Frame`; `PublicFrame` has no top-level
|
||||
`cells`/`facilities` — they live inside `districts[]`, one per seat, which is where nearly all the
|
||||
bytes are. One accepted intent changes one district, so a whole-array comparison resends every other
|
||||
player's board on every step. Keep `deltaFrame`/`applyDelta`'s "null means unchanged" convention;
|
||||
replace the key set.
|
||||
|
||||
**`protocolVersion` goes on the ENVELOPE, not on `PublicFrame`.** The original sketch put it inside
|
||||
the frame; it does not belong there. `PublicFrame`'s property list is an allow-list that
|
||||
`test/redaction.test.ts` enumerates, so a transport concern living in it would have to be declared
|
||||
public *game state*, which it is not. The step is the message; the message carries the version.
|
||||
Built as `DISPLAY_PROTOCOL_VERSION` in `sim/display-step.ts`.
|
||||
|
||||
### 4. Foreign-district rendering — THIS IS #13
|
||||
|
||||
`Frame.cells` is one district, the viewer's own. A step stream without this is data with nowhere to
|
||||
go, so this is the feature rather than a supporting part of it.
|
||||
|
||||
- Render any seat's district from `PublicDistrict`.
|
||||
- **Focus follows the actor** — the district panel shows whoever is acting, theirs while they switch
|
||||
and yours when it is your turn. Same rule the seatless board will use in 0.8.1: acting player's
|
||||
seat, else most recent actor's seat, else seat zero.
|
||||
- **Resolve owner from seat on every frame**, so Employee Rotation relabels a district in place
|
||||
rather than moving it.
|
||||
- `Frame` already carries `whereFrom` — *"Origin of a Move, so the crew's journey is visible rather
|
||||
than a chip teleporting"* — which is the same idea for the viewer's own moves. Extend it, don't
|
||||
invent a second one.
|
||||
|
||||
### 5. Pacing — dwell by kind
|
||||
|
||||
**Measured 2026-09-09, and the measurements decide the model.** From `public/replays/`: ~60 stages
|
||||
per game and ~5 intents per player per stage, so a 4-player table generates **~15 other-player steps
|
||||
per stage**. Burst sizes inside one switching turn, in the switching-heavy seed
|
||||
(`seed-1917398`): **14, 6, 6, 6** — and `trayMoved`'s own narration says *"N of **6** Moves left"*,
|
||||
so six is the engine's cap per crew. Two of the three published replays contain **zero**
|
||||
`switch.move`: bot switching is clustered, not spread.
|
||||
|
||||
So a uniform budget spread over the queue does exactly the wrong thing — it steals time from the
|
||||
6-move switching burst to spend on the 44 `draw.end` and 60 `loadUnload.end`. **Assign dwell by kind
|
||||
and let the total fall out.**
|
||||
|
||||
`sim/pacing.ts` — shared, so the 0.8.1 seatless page paces identically and the TV and the play screen
|
||||
never disagree about how fast the game looks:
|
||||
|
||||
```ts
|
||||
export type StepKind = 'switching' | 'action' | 'bookkeeping';
|
||||
|
||||
/** THE TUNING TABLE. Dwell in ms per kind. Start generous; tune down by playing. */
|
||||
export const DWELL: Record<StepKind, number> = {
|
||||
switching: 1000, // switch.move / dropCars / sortConsist / maneuver.*
|
||||
action: 250, // draw.from* / card.* / newTrain.placeCar / porter.* / laborer.*
|
||||
bookkeeping: 0, // *.end, and any phase where nothing happened
|
||||
};
|
||||
```
|
||||
|
||||
Jesse 2026-09-09: **start switching at 1s and tune down**, and *"make sure that the tuning parameters
|
||||
for the delays are easy to set."* Three levels, deliberately:
|
||||
|
||||
| Level | Where | Reach |
|
||||
| --- | --- | --- |
|
||||
| Committed default | the table above | needs a web rebuild — in the `.s9pk`, a release |
|
||||
| Live per-viewer | `pace` in `Settings` (`localStorage`), a multiplier; **`0` = off** | no rebuild |
|
||||
| Per-session | `?pace=` URL parameter | matches the existing `?seed=` convention; hand two testers different links |
|
||||
|
||||
**Not in game-creation settings.** Jesse, 2026-09-09: *"for now, they should not be in the game
|
||||
creation settings, but we may want to put them there later."* Correct on its own terms — dwell is
|
||||
presentation, not a rule, and `config` rides along in saves and replays. `Settings` already carries
|
||||
the argument for this: *"A save is the seed plus the intents and has to stay portable; none of this
|
||||
belongs in it, and in a multiplayer game two players may reasonably want these set differently."*
|
||||
The migration path is cheap: because the table is shared and the override is one scalar, moving it to
|
||||
game config later means adding a config field that supplies the multiplier's default. The table, the
|
||||
classification and the queue do not change.
|
||||
|
||||
**Pacing is client-side only.** The server emits steps as fast as it likes, which is what keeps
|
||||
Gitea#20's *"do not slow the authoritative game"* true.
|
||||
|
||||
**Arithmetic, because it is the reassuring part:** ~16s of switching plus ~25s of one-shots ≈ **40s
|
||||
of animation across a whole 60-stage game**, against 3.6 minutes for a uniform 700ms. Tiering gives
|
||||
*better* switching visibility for a fifth of the total time. One complete switching exercise is
|
||||
6 × 1s = 6s to watch.
|
||||
|
||||
### 6. The behind-counter, the caption, and skip
|
||||
|
||||
**Jesse's design, 2026-09-09**, and it resolves the "it's your turn but the board is stale" question
|
||||
that had two unattractive answers before it (hold the turn indicator, or show both silently):
|
||||
|
||||
> *"If I saw the counter as I'm watching the board go 17, 16, 15 … and I got impatient, and I could
|
||||
> just click a button and have it skip all the rest."*
|
||||
|
||||
One row, not three additions — the slot between `#turnchart` and `<main>` already holds
|
||||
`#phasenote`, `#announce` and `#presence`, and the palette is spoken for (violet = where you are,
|
||||
amber = clickable, green/red = good/bad):
|
||||
|
||||
```
|
||||
[13 behind] Player Alice moved Train 12 (1,2) → (1,3) via (1,1) [Skip]
|
||||
```
|
||||
|
||||
- The counter **is** `queue.length` — client-side, derived, **zero protocol impact**.
|
||||
- **Count only steps that will dwell.** With bookkeeping at 0ms, a backlog of 17 where 12 are `*.end`
|
||||
would read "17", plummet to 5 instantly, then crawl. Thirteen dwelling steps means thirteen things
|
||||
you are going to watch.
|
||||
- The caption is **#15**. TODO's Reference · #15 asks what the unit should be and concludes that in
|
||||
multiplayer it is *"everything that happened while you were WAITING"* — which is what the queue
|
||||
holds. So #15 is this row, not a separate feature.
|
||||
- **Skip costs the animation and never the information.** Everything skipped is already in the history
|
||||
log. That is what makes the button safe to press without hesitation.
|
||||
- Amber for the button. Self-hides at zero, so solitaire only sees it during an automatic-phase run.
|
||||
- **Input is never blocked.** Any input skips to current. `pace = 0` turns the whole thing off, which
|
||||
is also TODO #18's *"a player who has seen it a hundred times will want it off"* — no second
|
||||
mechanism for it.
|
||||
|
||||
### 7. #18 becomes a dwell setting
|
||||
|
||||
Not a feature. Phases where nothing happened dwell at zero — Jesse, 2026-09-09: *"if nothing happens
|
||||
during a phase then we shouldn't lose time to it"*, and on solitaire, *"you kind of look to see and
|
||||
guess, 'Oh, I guess nothing happened in those phases'"*, which is acceptable. Phases where something
|
||||
happened get a beat through the same queue.
|
||||
|
||||
**A flat second per phase is explicitly rejected**: 5 phases × ~60 stages is about five minutes per
|
||||
game of enforced dwell, most of it spent on phases where nothing happened. TODO's Reference · #18
|
||||
reached the same figure from the other direction (48s/Day).
|
||||
|
||||
Solitaire inherits all of this through `LocalSession` rather than being special-cased.
|
||||
|
||||
### 8. Carried along — switching logs unattributed
|
||||
|
||||
`record()` attributes with `const mine = who !== null && 'player' in e` (`web/game.ts:1188`).
|
||||
**`trayMoved`, `carsCoupled`, `carsDropped` and `consistSorted` carry no `player` field** — measured
|
||||
2026-09-09, and they are the only events in their class that do not. `cardDrawn`, `cardPlayed`,
|
||||
`cardDiscarded`, `carPlacedOnTrain`, `loadStarted`, `loadCompleted`, `flyingSwitch` and
|
||||
`localOpsOptionChosen` all do.
|
||||
|
||||
So switching — the one class of action Jesse most wants to follow — logs unattributed and with
|
||||
`tone: 'plain'` instead of `'act'`, meaning it does not even read as somebody's move:
|
||||
|
||||
```
|
||||
Player Alice chose to switch ← attributed
|
||||
CREW moved (1,2) → (1,3) — 4 of 6 ← whose train?
|
||||
CREW coupled 2 cars ← whose?
|
||||
Player Alice finished Local Operations ← attributed
|
||||
```
|
||||
|
||||
An attributed bracket around unattributed contents. Add `player` to those four events. Fixed here
|
||||
rather than filed, because it is the same feature.
|
||||
|
||||
### Tests
|
||||
|
||||
- Collector: one human intent with no bot response; one human intent followed by several bot intents;
|
||||
consecutive bot turns; bot pending decisions; automatic engine work inside one intent; ordering of
|
||||
narration against frames; sequence continuity; player pushes still coalesced; **no steps emitted
|
||||
during `resumeSession()`**; reconstructed state matching `publicSnapshot()` after the final step.
|
||||
- Public delta: per-seat district deltas reconstruct a frame identical to a fresh full projection.
|
||||
- Redaction: **done 2026-09-09**, by folding the steps into `everythingSeatSees` so that every
|
||||
existing case covers them — the blind draw, the pending decision, Employee Rotation before and
|
||||
after the seating moves, the reconnect and the played-out game — rather than adding one test beside
|
||||
them. Doing it surfaced two false positives in the existing name-based heuristic, neither caused by
|
||||
this feature, and the distinction they forced is worth keeping:
|
||||
- **A card NAME is circumstantial evidence; a card ID is proof.** Ids are searched everywhere. Names
|
||||
are not searched in two places that are legitimately entitled to carry them: lines naming a
|
||||
**face-up pile** (§2.6 — a Department or the Salvage Yard is public, so "Ann discarded Train 6
|
||||
face-up on Department 3" is the record working, and it stays in the log after she takes it back),
|
||||
and the **accumulated step frames**, which are a record of what was public *over time* rather
|
||||
than a view of the position now. What guarantees a step frame is clean is the allow-list test on
|
||||
`PublicFrame`, not a substring search over its history.
|
||||
- The harness also passed `g.log` into `snapshot()` for the Frame's own `lines`, which **production
|
||||
has not done since #97**. Now `[]`, matching `frameFor()`. The log is still audited in full, once.
|
||||
- **Verified by injecting the v0.7.9.2 blind-draw leak and confirming the net still fails** — both
|
||||
the dedicated test and, independently, the new step coverage. A relaxed safety test that has not
|
||||
been shown to still bite is not a safety test.
|
||||
- Pacing: kind classification for every intent type in the `Intent` union, so a new intent cannot
|
||||
land silently in the wrong tier; dwell arithmetic against the table; `pace = 0` produces no dwell.
|
||||
- Queue: step order preserved; skip drains and applies final state; counter counts dwelling steps
|
||||
only; a stopped queue stops its timers.
|
||||
- Attribution: each of the four switching events narrates with the acting player's name and `act`
|
||||
tone.
|
||||
- Solitaire: `LocalSession` produces the same steps for the same intents as `GameSession` does.
|
||||
|
||||
## Summary
|
||||
|
||||
Add a privacy-safe common game board that can be viewed in a browser and published into the game’s Jitsi meeting by a server-managed headless Chromium participant.
|
||||
@@ -29,6 +327,8 @@ The implementation is divided into independently useful stages:
|
||||
6. Supervise one headless Chromium process per published game.
|
||||
7. Integrate lifecycle, configuration, packaging, health, and live verification.
|
||||
|
||||
Steps 1-4 are **v0.8.0** (1 shipped); steps 5-7 are **v0.9.0** — § THE RELEASE SPLIT.
|
||||
|
||||
The browser display remains useful without Jitsi. The public projection and leak fixes improve multiplayer security even if no visual display is deployed.
|
||||
|
||||
## Research incorporated
|
||||
@@ -61,15 +361,20 @@ Introduce dedicated allow-listed types. Do not derive them with `Omit<Frame, ...
|
||||
> **Read those two, not this**, when building steps 2-7. The differences that matter:
|
||||
>
|
||||
> - **The shape is FLAT, not grouped.** There is no `clock`, `config`, `scoring` or `deckCounts`
|
||||
> object. Their contents sit at the top level — `day`, `stage`, `clock` (a time string), `phase`,
|
||||
> `phaseKey`, `actor`, `superintendent`, `deck`, `departments`, `departmentDepth`, `salvage`,
|
||||
> `yards`, `mode`, `days`, `optionalRules`, `houseRules`, `minCombinedRevenue`,
|
||||
> `maxCollisionsPerDay`, `maxCollisionsTotal`, `collisionsToday`, `collisionsTotal`, `status`,
|
||||
> `outcome`, `extraDays`, `extensionVotes`, `official`, `tally`, `openingRolls`, `timetable`,
|
||||
> `timetableWhat`, `trains`, `players`, `division`, `districts`.
|
||||
> - **`protocolVersion` was NOT built** and exists nowhere in the repo. Step 2 is the reconnecting
|
||||
> display stream, which is the first thing that would want one — decide there whether to add it,
|
||||
> rather than assuming it is already on the wire.
|
||||
> object. Their contents sit at the top level. **All 38 properties, which is the same list as
|
||||
> `test/redaction.test.ts`'s allow-list** — `day`, `stage`, `clock` (a time string), `phase`,
|
||||
> `phaseKey`, `actor`, `superintendent`, `deck`, `departments`, `departmentsWhat`,
|
||||
> `departmentDepth`, `salvage`, `yards`, `timetable`, `timetableWhat`, `houseRules`, `mode`,
|
||||
> `optionalRules`, `days`, `minCombinedRevenue`, `maxCollisionsPerDay`, `maxCollisionsTotal`,
|
||||
> `collisionsToday`, `collisionsPrevDay`, `collisionsTotal`, `status`, `outcome`, `extraDays`,
|
||||
> `extensionVotes`,
|
||||
> `official`, `tally`, `players`, `openingRolls`, `trains`, `crewTrays`, `queued`, `division`,
|
||||
> `districts`. The first 36 come from `projectSharedTable`; `division` and `districts` are added
|
||||
> by `PublicFrame` itself.
|
||||
> - **`protocolVersion` was NOT built** and exists nowhere in the repo. **Decided 2026-09-09: add it
|
||||
> in step 2.** `display.json` carries its own `schemaVersion`, and the SSE wire format is a second,
|
||||
> independent contract — parsed in 0.9.0 by a headless agent page that lives for hours, where
|
||||
> `build-web.ts`'s cache-busting does not help.
|
||||
> - **`redFlagHeld` is STRUCK OFF**, not deferred. See below.
|
||||
> - **Fields gained since this was written** that the renderer should know about: `crewTrays` and
|
||||
> `queued` (#98, the Crew Tray pool and the trains waiting for one), and on each district's cells
|
||||
@@ -275,6 +580,13 @@ Cover:
|
||||
Acceptance requires an allow-list review of every `PublicFrame` property. Passing redaction tests alone is insufficient.
|
||||
|
||||
## Step 2 — Display credentials, stream, and persistence
|
||||
> **SPLIT (2026-09-09).** The **display-step stream** part of this step is superseded by § v0.8.0,
|
||||
> which puts steps on the `Session` interface and on `Push.steps` — no endpoint, no credential, and
|
||||
> no change to `http.ts`. What remains here is **v0.8.1**: `display.json`, the `viewToken`, the
|
||||
> `/api/display/stream` SSE endpoint, `/display.html`, the security requirements, and the
|
||||
> `startServer()` refactor plus the first HTTP test harness that testing those needs. `protocolVersion`
|
||||
> and the per-seat public delta moved into 0.8.0. Findings below are from 2026-08-27 and unverified.
|
||||
|
||||
|
||||
### Display metadata
|
||||
|
||||
@@ -305,22 +617,32 @@ For older saves without `display.json`, generate the metadata once on resume and
|
||||
|
||||
### HTTP endpoints
|
||||
|
||||
Add:
|
||||
Add in **0.8.0**:
|
||||
|
||||
- `GET /display.html#token=<viewToken>` — manual common-board page
|
||||
- `GET /api/display/stream?token=<viewToken>` — public-board SSE
|
||||
|
||||
Deferred to **0.9.0** with the publisher (§ THE RELEASE SPLIT):
|
||||
|
||||
- `GET /display-agent.html` — internal headless publisher page
|
||||
- WebSocket upgrade at `/api/display/control` — supervisor/agent control
|
||||
- the supervisor/agent control channel, as **SSE down + POST up**, not a WebSocket upgrade
|
||||
|
||||
Extend the authenticated game/session response with:
|
||||
|
||||
- display URL
|
||||
- Jitsi meeting URL
|
||||
- sanitized publisher state
|
||||
- sanitized publisher state (`'disabled'` until 0.9.0)
|
||||
|
||||
The Jitsi meeting URL joins that response in 0.9.0.
|
||||
|
||||
The browser display reads the fragment token, removes it from the visible address if practical, and supplies it to the SSE request. Fragments keep the credential out of the initial HTTP request and normal server access logs.
|
||||
|
||||
Do not add a publisher-configuration HTTP endpoint. The internal agent receives its meeting configuration and view token over the authenticated control WebSocket.
|
||||
Do not add a publisher-configuration HTTP endpoint. The internal agent receives its meeting configuration and view token over its authenticated control channel.
|
||||
|
||||
**0.8.0 must not presume an upgrade handler exists.** `startServer()` in `src/server/http.ts:169`
|
||||
returns `void` today and there are no HTTP-level tests in the project at all — `test/server/` is
|
||||
lobby, persistence and session only. Step 2 therefore builds the project's first HTTP test harness
|
||||
on top of the `startServer()` refactor, which is a larger opening move than the one line it gets
|
||||
below. TODO #102 is the record of why this half of the codebase was untested until v0.7.9.8.
|
||||
|
||||
### SSE behavior
|
||||
|
||||
@@ -336,6 +658,14 @@ On connection:
|
||||
|
||||
Use a dedicated public-frame delta function rather than the player `Frame` delta. The observed public snapshot grows enough during longer games that full frames for every action would be wasteful.
|
||||
|
||||
**This is new code, not a reuse, and the shape differs from the player delta** (checked 2026-09-09).
|
||||
`src/sim/frame-delta.ts` hardcodes `BOARD_KEYS = ['cells', 'facilities', 'division']` against
|
||||
`Frame`, but `PublicFrame` has no top-level `cells` or `facilities` — they live inside `districts[]`,
|
||||
one entry per seat, which is where nearly all of the bytes are. **Delta `districts` per seat, not as
|
||||
one array**: a single accepted intent changes one district, so a whole-array comparison sends every
|
||||
other player's board again on every step. `deltaFrame`/`applyDelta`'s "null means unchanged, merged
|
||||
against the last full frame the receiver holds" convention is worth keeping; the key set is not.
|
||||
|
||||
If an SSE client is slow or disconnected, close it and let EventSource reconnect to a new reset. Do not keep an unbounded replay buffer.
|
||||
|
||||
### Security requirements
|
||||
@@ -365,6 +695,15 @@ Cover:
|
||||
- display failure not interrupting `/api/intent` or player SSE
|
||||
|
||||
## Step 3 — Reusable common-board renderer
|
||||
> **SPLIT (2026-09-09).** The **canvas** half of this step exists only to feed WebRTC —
|
||||
> `canvas.captureStream(10)` is the only way to hand a rendered board to Jitsi, and DOM cannot be
|
||||
> captured into a `MediaStream`. A TV or an OBS browser source renders HTML directly, and more
|
||||
> crisply. So: the 1280×720 canvas, the 10fps draw loop, `contentHint: 'detail'`, SVG→image
|
||||
> rasterization and the content-keyed image cache are all **v0.9.0**. The **layout** work — an
|
||||
> all-districts seatless page, and adapting the Division/office SVG generators to render without a
|
||||
> private viewer — is **v0.8.1**, and is cheap once 0.8.0's foreign-district rendering exists. The
|
||||
> animation queue and the focused-district rule moved into 0.8.0 (§ v0.8.0 §§ 4, 6).
|
||||
|
||||
|
||||
### Renderer structure
|
||||
|
||||
@@ -447,9 +786,30 @@ Test renderer lifecycle with a fake canvas/image layer:
|
||||
Perform visual review at 1280×720 and as a reduced Jitsi tile. Text and train positions must remain legible without opening a tooltip.
|
||||
|
||||
## Step 4 — Preserve individual human and bot actions
|
||||
> **SUPERSEDED BY § v0.8.0 (2026-09-09).** Read that section, not this one. The collector design here
|
||||
> is broadly right and its "Current code findings" were re-verified 2026-09-09, so both are kept —
|
||||
> but three things changed: the collector lives in `sim/` and is called by `LocalSession` too rather
|
||||
> than being a `GameSession` private; *"Keep player pushes unchanged"* below is **reversed** (that was
|
||||
> the open question, and #13 is the answer); and the mechanism now includes foreign-district
|
||||
> rendering, pacing by kind, and the behind-counter, none of which are described here.
|
||||
|
||||
|
||||
### Current code findings
|
||||
|
||||
> **RE-VERIFIED 2026-09-09.** The three findings below still hold, and the insertion point is as
|
||||
> cheap as they imply: `intent()` (`src/server/session.ts:361`) is synchronous — `check` → `submit`
|
||||
> → `driveBotTurns()` → `pushesForAll()` — and `driveBots()` (`:320`) loops `submit()`. A collector
|
||||
> after each `submit()` is two call sites and needs no async surgery. **Two traps that are not in
|
||||
> the text below and were found by reading the code rather than the plan:**
|
||||
>
|
||||
> - **The collector must be inert during replay.** `resumeSession()` rebuilds a game by replaying
|
||||
> its whole history through `submit()`. Wired naively, every server restart re-emits the entire
|
||||
> game as display steps and burns `nextSequence` in `display.json`. This is the same class of bug
|
||||
> as #97 — a mechanism firing on a path nobody pictured it running on.
|
||||
> - **Narration-per-step needs its own high-water mark.** `sentLines` is per-seat and is *mutated*
|
||||
> by `linesSince()` as a side effect of building a push, so step 2 of "Required changes" cannot
|
||||
> read it. Track an independent mark against `game.log.length`.
|
||||
|
||||
`GameSession.intent()` applies the human intent, runs `driveBots()`, and only then creates player pushes.
|
||||
|
||||
`driveBots()` can call `submit()` many times. Player deltas intentionally collapse those moves into one final state, which is appropriate for gameplay but would make bots appear to teleport through several actions on the common board.
|
||||
@@ -480,6 +840,24 @@ One display step corresponds to one accepted intent, including automatic consequ
|
||||
|
||||
Keep player pushes unchanged: players still receive the final coalesced result after all immediately due bots finish.
|
||||
|
||||
> **REVERSED FOR 0.8.0 — RESOLVED 2026-09-09. See § v0.8.0.**
|
||||
>
|
||||
> The sentence above scoped step 4 to the seatless board. It was the open question of this design and
|
||||
> it now has an answer: **seated players receive steps too**, because #13 is what 0.8.0 is for.
|
||||
>
|
||||
> How the three objections that made it an open question were settled:
|
||||
>
|
||||
> - *A player waiting to act cannot lag a second behind.* Answered by the **behind-counter and skip**
|
||||
> (§ v0.8.0 § 6) rather than by policy — the lag is shown, counted down, and skippable, so the
|
||||
> player decides instead of the design guessing.
|
||||
> - *A step sent to a seat is a `Frame`, and `Frame`s are redacted per seat.* Answered by **not
|
||||
> sending a `Frame`**: public steps animate the board and the existing private Push supplies hand,
|
||||
> menu and objective. Zero new redaction surface.
|
||||
> - *Animating other people's turns makes the game feel slower.* Answered by **dwell by kind** — the
|
||||
> measured cost is ~40s of animation across a 60-stage game, and bookkeeping dwells at zero.
|
||||
>
|
||||
> Player pushes are still coalesced; what changes is that they now also carry `steps`.
|
||||
|
||||
Opening bot moves that occur before any client connects do not need replay. Persist the resulting game state and sequence; a later display receives the final reset.
|
||||
|
||||
### Failure isolation
|
||||
@@ -509,6 +887,14 @@ Cover:
|
||||
|
||||
## Step 5 — Minimal visual-only Jitsi engine
|
||||
|
||||
> **v0.9.0 — DEFERRED (2026-09-09).** Steps 5-7 are the Jitsi publisher and are explicitly held
|
||||
> off until 0.8.0 ships and Chromium has been measured on the target hardware. Nothing below has been
|
||||
> re-verified against the current code; it was written 2026-08-27. Two things will have moved by
|
||||
> the time it is picked up: the control channel is **SSE + POST, not a WebSocket** (§ THE RELEASE
|
||||
> SPLIT), and adding Chromium takes the `.s9pk` from 63 MB to several hundred against a package
|
||||
> that declares no `hardwareRequirements` today.
|
||||
|
||||
|
||||
### Source strategy
|
||||
|
||||
Port the smallest relevant production patterns from `jitsi-transcription` into Station Master with attribution where required. Do not import the sibling repository at runtime, add it as a submodule, or copy its transcription/audio/chat features.
|
||||
@@ -530,7 +916,12 @@ Pin the known working `lib-jitsi-meet` release:
|
||||
v2192.0.0+d6f3312f
|
||||
```
|
||||
|
||||
Add `ws` for the Node control broker. Do not add Playwright.
|
||||
**Do not add `ws`, and do not add Playwright.** The control channel is SSE down + POST up
|
||||
(§ THE RELEASE SPLIT), so it needs nothing beyond `node:http`, which the server already uses.
|
||||
`lib-jitsi-meet` is browser-side only: vendor the pinned release into `public/` and load it with a
|
||||
`<script>` tag — there is no bundler in this project, `scripts/build-web.ts` runs bare `tsc`. Pin
|
||||
and checksum it; it is a multi-megabyte minified blob entering the repo, which is a decision to
|
||||
take deliberately rather than a build artefact.
|
||||
|
||||
### Agent page
|
||||
|
||||
@@ -547,7 +938,7 @@ Add `ws` for the Node control broker. Do not add Playwright.
|
||||
9. Report normalized lifecycle state over the control channel.
|
||||
10. Leave and stop all tracks on supervisor command or `pagehide`.
|
||||
|
||||
Meeting server, room, XMPP configuration, and view token are delivered over the control WebSocket, not placed in query parameters.
|
||||
Meeting server, room, XMPP configuration, and view token are delivered over the control channel, not placed in query parameters.
|
||||
|
||||
### Jitsi publishing
|
||||
|
||||
@@ -636,6 +1027,14 @@ Port/adapt the sibling repository’s proven tests for:
|
||||
|
||||
## Step 6 — Chromium publisher supervisor
|
||||
|
||||
> **v0.9.0 — DEFERRED (2026-09-09).** Steps 5-7 are the Jitsi publisher and are explicitly held
|
||||
> off until 0.8.0 ships and Chromium has been measured on the target hardware. Nothing below has been
|
||||
> re-verified against the current code; it was written 2026-08-27. Two things will have moved by
|
||||
> the time it is picked up: the control channel is **SSE + POST, not a WebSocket** (§ THE RELEASE
|
||||
> SPLIT), and adding Chromium takes the `.s9pk` from 63 MB to several hundred against a package
|
||||
> that declares no `hardwareRequirements` today.
|
||||
|
||||
|
||||
### Process model
|
||||
|
||||
Create one Chromium child process per published game.
|
||||
@@ -699,7 +1098,8 @@ The broker must:
|
||||
- allow a newer engine to supersede only the same session
|
||||
- reject client/engine role changes on an established socket
|
||||
- clear pending commands when an engine disconnects
|
||||
- reconnect the agent-side WebSocket every two seconds until stopped
|
||||
- reconnect the agent-side control stream every two seconds until stopped — note that an
|
||||
`EventSource` does this by itself, which is part of why the transport changed
|
||||
|
||||
Public frames remain on SSE and never traverse this control protocol.
|
||||
|
||||
@@ -760,6 +1160,14 @@ Use fake child processes and fake control sockets to test:
|
||||
|
||||
## Step 7 — Configuration, lifecycle, packaging, and observability
|
||||
|
||||
> **v0.9.0 — DEFERRED (2026-09-09).** Steps 5-7 are the Jitsi publisher and are explicitly held
|
||||
> off until 0.8.0 ships and Chromium has been measured on the target hardware. Nothing below has been
|
||||
> re-verified against the current code; it was written 2026-08-27. Two things will have moved by
|
||||
> the time it is picked up: the control channel is **SSE + POST, not a WebSocket** (§ THE RELEASE
|
||||
> SPLIT), and adding Chromium takes the `.s9pk` from 63 MB to several hundred against a package
|
||||
> that declares no `hardwareRequirements` today.
|
||||
|
||||
|
||||
### Configuration
|
||||
|
||||
Support:
|
||||
@@ -844,8 +1252,10 @@ Exclude all tokens, full control frames, player private state, query strings, an
|
||||
|
||||
Add runtime dependencies:
|
||||
|
||||
- the pinned `lib-jitsi-meet` release
|
||||
- `ws`
|
||||
- the pinned `lib-jitsi-meet` release, vendored as a browser asset rather than an npm runtime import
|
||||
|
||||
**No Node runtime dependency is added.** `package.json` has no `dependencies` key and the wrapper's
|
||||
runtime image copies only `package.json` and `src/` — see § THE RELEASE SPLIT. Keep it that way.
|
||||
|
||||
Add Chromium and `tini` to the Station Master runtime image or companion StartOS packaging repository.
|
||||
|
||||
@@ -912,7 +1322,7 @@ Capture:
|
||||
|
||||
- raw display SSE
|
||||
- rendered canvas screenshots
|
||||
- control WebSocket messages
|
||||
- control-channel messages
|
||||
- Jitsi network destinations
|
||||
- server logs
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "station-master",
|
||||
"version": "0.7.9.8",
|
||||
"version": "0.8.0.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Station Master — a railroad operations game",
|
||||
|
||||
@@ -1638,6 +1638,9 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
|
||||
s.clock.day += 1;
|
||||
s.clock.stage = 1;
|
||||
// Captured BEFORE the reset: the Day-end dialog reports the Day that just finished, and it is
|
||||
// drawn from the frame this rollover produces. See `collisionsPrevDay` in `state.ts`.
|
||||
s.collisionsPrevDay = s.collisionsToday;
|
||||
s.collisionsToday = 0;
|
||||
events.push({ type: 'stageBegan', day: s.clock.day, stage: 1 });
|
||||
rotateSeats(s, events);
|
||||
|
||||
+41
-4
@@ -1642,6 +1642,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
const events: GameEvent[] = [
|
||||
{
|
||||
type: 'trayMoved',
|
||||
player,
|
||||
trayId: i.trayId,
|
||||
from,
|
||||
to: i.to,
|
||||
@@ -1706,6 +1707,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
// decides which car is next to come off.
|
||||
events.push({
|
||||
type: 'carsCoupled',
|
||||
player,
|
||||
trayId: i.trayId,
|
||||
at: i.to,
|
||||
stock: dest.couples,
|
||||
@@ -1726,7 +1728,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
const stock = i.fromNose
|
||||
? tray.consist.slice(0, i.count)
|
||||
: tray.consist.slice(tray.consist.length - i.count);
|
||||
return [{ type: 'carsDropped', trayId: i.trayId, at: here, stock, ...(i.fromNose ? { fromNose: true } : {}) }];
|
||||
return [{ type: 'carsDropped', player, trayId: i.trayId, at: here, stock, ...(i.fromNose ? { fromNose: true } : {}) }];
|
||||
}
|
||||
|
||||
case 'switch.sortConsist': {
|
||||
@@ -1735,6 +1737,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
return [
|
||||
{
|
||||
type: 'consistSorted',
|
||||
player,
|
||||
trayId: i.trayId,
|
||||
at: here,
|
||||
before: tray.consist.map((c) => ({ ...c })),
|
||||
@@ -2239,7 +2242,8 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
// Everything swept comes back as ONE pile, then §4.6-4.7's opening is re-run: three cards
|
||||
// turned face up as the Departments, the rest face down as the Home Office deck. The
|
||||
// Departments start one deep again, exactly as at setup.
|
||||
s.decks.salvageYard = [];
|
||||
// The spent trains stay where they are; everything else in the Yard has just been swept up.
|
||||
s.decks.salvageYard = s.decks.salvageYard.filter((id) => isSpentTimetabledTrain(s, id));
|
||||
s.decks.departments = [[], [], []];
|
||||
const order = [...e.order];
|
||||
for (const pile of s.decks.departments) {
|
||||
@@ -2470,7 +2474,15 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
case 'trainScheduled':
|
||||
s.timetable[e.slot] = e.trainNumber;
|
||||
s.rngState = e.rngState;
|
||||
s.decks.salvageYard.push(`train-${e.trainNumber}`);
|
||||
/**
|
||||
* THE CARD IS ALREADY IN THE SALVAGE YARD — `cardPlayed` put it there, by its real id.
|
||||
*
|
||||
* This used to push a second, SYNTHETIC `train-<number>` beside it, so scheduling four trains
|
||||
* left eight entries in a pile holding four cards. Nothing ever read that id: it inflated the
|
||||
* pile's depth, it displayed as "a card" because no such card exists, and
|
||||
* `reshuffleIfDepleted` would have swept it into the draw deck to be drawn as an id with
|
||||
* nothing behind it. Removed 2026-09-10 (Gitea#23).
|
||||
*/
|
||||
break;
|
||||
|
||||
case 'carPlacedOnTrain': {
|
||||
@@ -2927,9 +2939,34 @@ function spendCard(s: GameState, player: PlayerIndex, cardId: CardId): void {
|
||||
* that has genuinely used every card ends on `DECK_EMPTY` rather than reshuffling an empty pile.
|
||||
* Cards played onto the board are NOT recovered: they are on the table, which is where they belong.
|
||||
*/
|
||||
/**
|
||||
* §6.2, AND THE RULING THAT SETTLES IT — Jesse, 2026-09-10 (Gitea#23).
|
||||
*
|
||||
* "Once you've played a regularly scheduled train and it's in the salvage deck, that train is
|
||||
* already on the timetable. It does not make sense to put that back into a reshuffled home deck to
|
||||
* get played again. By contrast, a regularly scheduled train that's in a discard pile could
|
||||
* potentially get reused later, and so should have that capability. Extras run one time and then
|
||||
* they're done — if they are in the Salvage deck, they should get shuffled back in so that they
|
||||
* could get run again."
|
||||
*
|
||||
* So the test is WHERE the card is, not only what it is. A timetabled train in the SALVAGE YARD was
|
||||
* played: its number is on the timetable and cannot be scheduled twice, so the card is spent and
|
||||
* stays out. The same card sitting in a DEPARTMENT was discarded, never played, and its slot is
|
||||
* still open — so it comes back with everything else. An Extra is a single run rather than a
|
||||
* standing slot, so a played one is free to be run again.
|
||||
*/
|
||||
function isSpentTimetabledTrain(s: GameState, id: CardId): boolean {
|
||||
return s.cards.get(id)?.kind.kind === 'timetabledTrain';
|
||||
}
|
||||
|
||||
function reshuffleIfDepleted(s: GameState, taking: number): GameEvent | null {
|
||||
if (s.decks.homeOffice.length > taking) return null;
|
||||
const collected = [...s.decks.salvageYard, ...s.decks.departments.flat()];
|
||||
const collected = [
|
||||
// The Salvage Yard, less the trains whose slots are already filled — see above.
|
||||
...s.decks.salvageYard.filter((id) => !isSpentTimetabledTrain(s, id)),
|
||||
// Every Department in full: a discarded train was never played, so it is still runnable.
|
||||
...s.decks.departments.flat(),
|
||||
];
|
||||
if (collected.length === 0) return null;
|
||||
const rng = createRng(s.rngState);
|
||||
return { type: 'deckReshuffled', order: rng.shuffle(collected), rngState: rng.getState() };
|
||||
|
||||
@@ -37,9 +37,10 @@ export type GameEvent =
|
||||
* more than one legal route to `to`, so the history can say which one ran rather than leaving a
|
||||
* choice the player made invisible in their own log.
|
||||
*/
|
||||
| { type: 'trayMoved'; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number; facing?: 'n' | 's' | 'e' | 'w'; via?: GridCoord }
|
||||
| { type: 'trayMoved'; player: PlayerIndex; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number; facing?: 'n' | 's' | 'e' | 'w'; via?: GridCoord }
|
||||
| {
|
||||
type: 'carsCoupled';
|
||||
player: PlayerIndex;
|
||||
trayId: TrayId;
|
||||
at: GridCoord;
|
||||
stock: RollingStock[];
|
||||
@@ -71,8 +72,8 @@ export type GameEvent =
|
||||
*/
|
||||
recoupled?: { at: GridCoord; stock: RollingStock[] };
|
||||
}
|
||||
| { type: 'carsDropped'; trayId: TrayId; at: GridCoord; stock: RollingStock[]; fromNose?: boolean }
|
||||
| { type: 'consistSorted'; trayId: TrayId; at: GridCoord; before: RollingStock[]; after: RollingStock[] }
|
||||
| { type: 'carsDropped'; player: PlayerIndex; trayId: TrayId; at: GridCoord; stock: RollingStock[]; fromNose?: boolean }
|
||||
| { type: 'consistSorted'; player: PlayerIndex; trayId: TrayId; at: GridCoord; before: RollingStock[]; after: RollingStock[] }
|
||||
| { type: 'cardDrawn'; player: PlayerIndex; source: 'homeOffice' | 'department'; slot?: number; cardId: CardId }
|
||||
/**
|
||||
* §6.2 — the Home Office deck ran out, so the Salvage Yard and all three Department decks were
|
||||
|
||||
@@ -431,6 +431,7 @@ export function createGame(opts: SetupOptions): GameState {
|
||||
turns: freshTurns(playerCount, MOVES_PER_LOCAL_OPS),
|
||||
movedThisPhase: new Set(),
|
||||
collisionsToday: 0,
|
||||
collisionsPrevDay: 0,
|
||||
collisionsTotal: 0,
|
||||
status: 'active',
|
||||
outcome: null,
|
||||
|
||||
@@ -1119,6 +1119,20 @@ export type GameState = {
|
||||
movedThisPhase: Set<TrayId>;
|
||||
/** §3.4 — resets at the start of each Day; checked against `config.maxCollisionsPerDay`. */
|
||||
collisionsToday: number;
|
||||
/**
|
||||
* What `collisionsToday` held for the Day that just ENDED — captured at the rollover, immediately
|
||||
* before the reset.
|
||||
*
|
||||
* The Day-end dialog exists to report the Day that finished, and it is drawn from the frame AFTER
|
||||
* the rollover, because that is the frame whose `day` went up. So it read `collisionsToday` as 0 no
|
||||
* matter what had happened: Jesse, 2026-09-09, at the end of a Day 1 with two collisions in it —
|
||||
* "it shows a total of two collisions, but zero today ... that does seem to be a contradiction".
|
||||
*
|
||||
* NOT DERIVABLE ON THE CLIENT. A Day turns over inside the phases that run themselves, so in
|
||||
* multiplayer the push that reports the new Day is the same push that reports the reset — a client
|
||||
* may never see the ended Day's final count to remember it.
|
||||
*/
|
||||
collisionsPrevDay: number;
|
||||
/** §3.4 — never reset; checked against `config.maxCollisionsTotal`. */
|
||||
collisionsTotal: number;
|
||||
/**
|
||||
|
||||
+55
-5
@@ -26,8 +26,10 @@ import { actionMenu, currentActor, fromMultiplayerSave, isOutOfTurn, newMultipla
|
||||
import type { Game, Menu } from '../web/game.ts';
|
||||
import { deltaFrame } from '../sim/frame-delta.ts';
|
||||
import type { FrameDelta } from '../sim/frame-delta.ts';
|
||||
import { snapshot, seatLabel } from '../sim/view.ts';
|
||||
import type { Frame } from '../sim/view.ts';
|
||||
import { publicSnapshot, snapshot, seatLabel } from '../sim/view.ts';
|
||||
import type { Frame, PublicFrame } from '../sim/view.ts';
|
||||
import { takeSteps } from '../sim/display-step.ts';
|
||||
import type { DisplayStep } from '../sim/display-step.ts';
|
||||
import { developerBot } from '../sim/bot.ts';
|
||||
|
||||
export type Push = {
|
||||
@@ -69,6 +71,29 @@ export type Push = {
|
||||
scheduled?: number | null;
|
||||
announcement?: string | null;
|
||||
justDrawn?: string | null;
|
||||
/**
|
||||
* ORDERED PRESENTATION STEPS — v0.8.0, TODO #13.
|
||||
*
|
||||
* A field on `Push` rather than a second SSE event type, following `presence`'s precedent and for
|
||||
* its stated reason (`http.ts`): one message shape for the client to parse. `http.ts` therefore
|
||||
* needs no change at all — `broadcastGame` forwards whatever this file builds.
|
||||
*
|
||||
* IDENTICAL IN EVERY SEAT'S PUSH, because a step carries the PUBLIC board and nothing else. A
|
||||
* player's own hand, menu and objective are not animated: they arrive on the same push, already
|
||||
* coalesced, exactly as they always have. That is what keeps the redaction surface at zero new
|
||||
* area — `test/redaction.test.ts` guards the projection these are built from.
|
||||
*/
|
||||
steps?: DisplayStep[];
|
||||
/**
|
||||
* The public board to start a step queue from — sent on a CONNECT, never on an update.
|
||||
*
|
||||
* Steps carry deltas against one chain shared by the whole table, so a client that has just
|
||||
* arrived (or come back) has nothing to merge the next delta onto and `applyPublicDelta` would
|
||||
* rightly throw. This is that baseline: the exact frame the chain has reached, so the next step
|
||||
* lands on it. A reconnecting client resets rather than replaying what it missed — the history
|
||||
* panel is what carries the words, and it is already sent whole on connect (#97).
|
||||
*/
|
||||
publicReset?: PublicFrame;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -211,11 +236,12 @@ function buildSession(
|
||||
return { cues, scheduled, announcement };
|
||||
}
|
||||
|
||||
function pushFor(seat: PlayerIndex, moment: Moment | null): Push {
|
||||
function pushFor(seat: PlayerIndex, moment: Moment | null, steps: DisplayStep[] = []): Push {
|
||||
const frame = frameFor(seat);
|
||||
const delta = deltaFrame(lastFrame.get(seat) ?? null, frame);
|
||||
lastFrame.set(seat, frame);
|
||||
const push: Push = { frame: delta, menu: menuFor(seat), lines: linesSince(seat) };
|
||||
if (steps.length > 0) push.steps = steps;
|
||||
if (moment) {
|
||||
if (moment.cues.length > 0) push.cues = moment.cues;
|
||||
if (moment.scheduled !== null) push.scheduled = moment.scheduled;
|
||||
@@ -228,9 +254,13 @@ function buildSession(
|
||||
|
||||
function pushesForAll(): Map<PlayerIndex, Push> {
|
||||
const moment = takeMoment();
|
||||
// Drained ONCE for the whole broadcast, not per seat: the steps are public and identical, and
|
||||
// `takeSteps` empties the collector, so draining inside the loop would give them to seat 0 and
|
||||
// an empty list to everybody else.
|
||||
const steps = takeSteps(game.display);
|
||||
const out = new Map<PlayerIndex, Push>();
|
||||
for (let seat = 0; seat < playerNames.length; seat++) {
|
||||
out.set(seat as PlayerIndex, pushFor(seat as PlayerIndex, moment));
|
||||
out.set(seat as PlayerIndex, pushFor(seat as PlayerIndex, moment, steps));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -341,6 +371,19 @@ function buildSession(
|
||||
// here rather than fired at the first client to arrive. (It also stops `game.cues` growing without
|
||||
// bound on a server, which nothing was draining before this.)
|
||||
takeMoment();
|
||||
/**
|
||||
* THE PRESENTATION STEPS THOSE TURNS PRODUCED GO WITH THEM (v0.8.0).
|
||||
*
|
||||
* Left in the collector they would be delivered on the FIRST broadcast after somebody connects —
|
||||
* but that client's `publicReset` is the board as it stands AFTER these very moves, so replaying
|
||||
* them onto it would draw positions the game had already left. The plan says as much: opening bot
|
||||
* moves need no replay, and a later display simply receives the final reset.
|
||||
*
|
||||
* This is the only moment the collector holds anything outside an intent. `pushesForAll()` drains
|
||||
* it synchronously at the end of every `intent()`, so between moves it is always empty — which is
|
||||
* what makes dropping here safe rather than a race with a seat that has not been sent them yet.
|
||||
*/
|
||||
takeSteps(game.display);
|
||||
|
||||
return {
|
||||
playerCount: playerNames.length,
|
||||
@@ -355,7 +398,14 @@ function buildSession(
|
||||
// blank history panel mid-game, with the server holding the whole log. `Push.lines` on a
|
||||
// connect IS the history, which is what lets the Frame stop carrying a second copy.
|
||||
sentLines.delete(seat);
|
||||
return pushFor(seat, null);
|
||||
const push = pushFor(seat, null);
|
||||
/**
|
||||
* The baseline for this client's step queue (v0.8.0). `game.display.last` is the exact frame
|
||||
* the shared delta chain has reached, so the next step merges onto it; before any step has
|
||||
* been collected there is no chain yet and a fresh projection is the same thing.
|
||||
*/
|
||||
push.publicReset = game.display.last ?? publicSnapshot(game.state);
|
||||
return push;
|
||||
},
|
||||
|
||||
intent(seat, seq, i) {
|
||||
|
||||
@@ -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 {
|
||||
tone: 'plain',
|
||||
where: e.to,
|
||||
text: `CREW moved ${at(e.from)} → ${at(e.to)}${e.via ? ` via ${at(e.via)}` : ''} — ${e.movesRemaining} of 6 Moves left. The crew chip on the grid carries the whole train with it.`,
|
||||
text: `Moved ${train(e.trayId)} ${at(e.from)} → ${at(e.to)}${e.via ? ` via ${at(e.via)}` : ''} — ${e.movesRemaining} of 6 Moves left. The crew chip on the grid carries the whole train with it.`,
|
||||
};
|
||||
case 'carsCoupled': {
|
||||
/**
|
||||
@@ -181,7 +181,7 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
return {
|
||||
tone: 'good',
|
||||
where: e.at,
|
||||
text: `SMALL YARD — consist re-ordered from [${carsLabel(e.before)}] to [${carsLabel(e.after)}], so the right car is now on the end and can be spotted`,
|
||||
text: `Used the SMALL YARD — consist re-ordered from [${carsLabel(e.before)}] to [${carsLabel(e.after)}], so the right car is now on the end and can be spotted`,
|
||||
};
|
||||
case 'carsDropped':
|
||||
// WHICH END. A cut comes off an outer end (§A.3) and the end decides everything that follows:
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* 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". **Multipliers above 1 are supported and expected** — Jesse asked
|
||||
* for 2 and 3 explicitly after the first play — up to `MAX_PACE`, and every tier scales together so
|
||||
* their relative weighting survives.
|
||||
*
|
||||
* 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 — and the announcement of what a
|
||||
* player is about to do.
|
||||
*
|
||||
* WAS 250ms, WHICH WAS WRONG, and wrong in the way that mattered most: an early-game bot turn has
|
||||
* no switching in it at all, so it was six steps of 250ms and 0ms — **750ms for a whole turn**.
|
||||
* Jesse, from the first real play on the test server: *"bot play was way too fast. I briefly saw
|
||||
* that it was the bot's office area then their turn was done."* His instruction had been "start at
|
||||
* 1s and tune down", and that was applied only to switching while this number was invented.
|
||||
*/
|
||||
action: 700,
|
||||
/**
|
||||
* 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';
|
||||
|
||||
/**
|
||||
* `localOps.choose` IS AN ANNOUNCEMENT, NOT BOOKKEEPING — moved out 2026-09-09 after the first
|
||||
* real play. It is the line that reads "Player Bot 1 chose to SWITCH — six Moves to shunt cars
|
||||
* around the yard": the heading for everything that follows, and at zero dwell nobody ever saw
|
||||
* it, so a bot's turn began with no indication of what it was about to do.
|
||||
*/
|
||||
case 'localOps.choose':
|
||||
return 'action';
|
||||
|
||||
// Ending a phase or a turn, and voting. Nothing to see: the consequences were the thing, and
|
||||
// there are more of these than of anything else.
|
||||
case 'loadUnload.end':
|
||||
case 'draw.end':
|
||||
case 'switch.end':
|
||||
case 'freightAgent.end':
|
||||
case 'game.extend':
|
||||
return 'bookkeeping';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The widest multiplier that is a speed rather than a mistake.
|
||||
*
|
||||
* `pace` has no lower surprise — 0 means off — but an unbounded upper one does: `?pace=300` from
|
||||
* somebody typing 3.00, or a corrupt `localStorage` value, would give a switching move a five-minute
|
||||
* dwell and look exactly like a frozen board. Twenty is far past any speed anyone would choose and
|
||||
* well short of unusable.
|
||||
*
|
||||
* RAISED FROM TEN 2026-09-10, because the ceiling turned out not to be theoretical: Jesse played at
|
||||
* 10× — the top of the ladder — and reported it *"still a bit fast, but followable"*. A control whose
|
||||
* slowest setting is not slow enough for the person using it has the wrong ceiling, not the right one
|
||||
* held firmly.
|
||||
*/
|
||||
export const MAX_PACE = 20;
|
||||
|
||||
/**
|
||||
* The speeds the on-screen control offers, slowest last.
|
||||
*
|
||||
* `0` is off: every move is drawn at once, as it was before v0.8.0 — TODO #18's "a player who has
|
||||
* seen it a hundred times will want it off". The ladder runs well past 1 because that is what the
|
||||
* first real play asked for: Jesse reached for 7×, and although the `?pace=` he used never took
|
||||
* effect (the splash replaces the query string, so the play page only ever saw `?lobby`), the wish
|
||||
* was real. Watching a bot shunt cars is the point of this feature, and it is worth as long as it
|
||||
* takes.
|
||||
*/
|
||||
export const PACE_LEVELS = [0, 0.5, 1, 2, 3, 5, 7, 10, 15, 20] as const;
|
||||
|
||||
/**
|
||||
* How long to show one step, in ms, at a given speed.
|
||||
*
|
||||
* `pace` scales every tier by the same factor, so **the tiers stay in proportion at any speed** — a
|
||||
* switching move outlasts an ordinary action at 0.5× and at 3× alike. That is deliberate: the
|
||||
* relative weighting is the design (a train moving is worth more attention than a card changing
|
||||
* hands), and the multiplier is only how fast the whole thing runs. `0` means do not animate at all.
|
||||
*/
|
||||
export function dwellFor(cause: StepCause, pace = 1): number {
|
||||
return Math.round(DWELL[kindOf(cause)] * Math.min(MAX_PACE, Math.max(0, pace)));
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of the speed control a PHASE gets — damped, not the full multiplier.
|
||||
*
|
||||
* Phases were pinned at their tabled beat in v0.8.0.3, because scaling them with everything else put
|
||||
* a wall of clock-ticking after a player's own move. That was right about the cost and wrong about
|
||||
* the need: at 10× the caption row goes past faster than the sentence on it can be read. Jesse,
|
||||
* 2026-09-10: *"phases displayed on the upper line go by too quickly still. Should be 4 times as
|
||||
* long — at a guess. Maybe use the speed multiplier for that too?"*
|
||||
*
|
||||
* So they scale, at a third of the rate. That lands exactly on his guess — 10× gives a phase four
|
||||
* times its tabled beat — while leaving 1× untouched, and it stays affordable because phase beats
|
||||
* cluster rather than accumulate: measured over 60 pushes, a push carries **1.0 phase beat on
|
||||
* average and 4 at worst**, so the wait after a move goes to ~2.4s typical and ~10s at its very
|
||||
* worst rather than the minutes a full multiplier would have cost.
|
||||
*
|
||||
* Below 1× it simply follows the multiplier: somebody asking for everything faster means the phases
|
||||
* too.
|
||||
*/
|
||||
function phaseSpeed(pace: number): number {
|
||||
return pace <= 1 ? pace : 1 + (pace - 1) / 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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; player: number | null; lines: readonly unknown[]; frame: { table: object } },
|
||||
pace = 1,
|
||||
): number {
|
||||
// Off means off, for the clock as much as for anybody's move.
|
||||
if (pace <= 0) return 0;
|
||||
/**
|
||||
* THE SPEED CONTROL IS ABOUT OTHER PEOPLE, NOT ABOUT THE CLOCK.
|
||||
*
|
||||
* A phase keeps its tabled beat at every speed. Measured over 40 turns of a real 3-seat game, the
|
||||
* waiting split almost evenly — 21.0s of other players against 21.0s of phases turning over — so
|
||||
* scaling both put 105 seconds of clock-ticking into a 5× game, all of it after the player's own
|
||||
* move and none of it anything to watch. Jesse, from that game: *"after my turn, when I actually
|
||||
* execute my turn, I'm still subject to that same delay before it moves on. That makes no sense."*
|
||||
*
|
||||
* The phase still gets its beat (TODO #18) — it just does not get longer because somebody wanted
|
||||
* to watch a bot shunt cars.
|
||||
*/
|
||||
const speed = step.player === null ? phaseSpeed(pace) : pace;
|
||||
if (step.lines.length > 0) return dwellFor(step.cause, speed);
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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, speed) : 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,175 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/** A face-up or face-down pile a card can move to or from, as the display addresses it. */
|
||||
export type PileKey = 'home' | 'salvage' | `dept${number}`;
|
||||
|
||||
/**
|
||||
* WHICH PILES A STEP MOVED — derived, never sent.
|
||||
*
|
||||
* The receiver already holds the frame before a step and the frame after it, so which pile changed
|
||||
* is a diff rather than something the wire has to carry. That matters twice over: nothing is added
|
||||
* to the protocol, and it cannot drift out of step with the projection the way a hand-maintained
|
||||
* hint would.
|
||||
*
|
||||
* WHY IT IS NEEDED AT ALL. A player watching somebody else draw a card sees seven seconds of an
|
||||
* unchanged board — the step holds the screen, and the only thing that moved is a number in a panel
|
||||
* they were not looking at. Jesse, playing v0.8.0.4 at 10×: *"many operations still occurred too fast
|
||||
* for me to see"*, which was never about duration. Lighting the pile is what tells the eye where.
|
||||
*
|
||||
* WHAT EACH ACTION MOVES, measured across four seeds rather than reasoned about:
|
||||
*
|
||||
* | intent | piles |
|
||||
* | ----------------------- | -------------------------------------------------------- |
|
||||
* | `draw.fromHomeOffice` | `home` — the COUNT only; the card itself stays private |
|
||||
* | `draw.fromDepartment` | that `dept`, and `home` too when the pile refills from it |
|
||||
* | `card.discard` | that `dept` |
|
||||
* | `card.play` | `salvage`, or nothing here when it lands on the board |
|
||||
* | switching, new trains | nothing here — those show on the board itself |
|
||||
*/
|
||||
export function changedPiles(before: PublicFrame | null, after: PublicFrame): PileKey[] {
|
||||
if (before === null) return [];
|
||||
const out: PileKey[] = [];
|
||||
if (before.deck !== after.deck) out.push('home');
|
||||
after.departmentDepth.forEach((depth, i) => {
|
||||
// The TOP as well as the depth: taking the face-up card and replacing it leaves the count alone
|
||||
// and changes the card everybody can see, which is the half that matters to a watcher.
|
||||
if (before.departmentDepth[i] !== depth || before.departments[i] !== after.departments[i]) {
|
||||
out.push(`dept${i}`);
|
||||
}
|
||||
});
|
||||
if (before.salvage.depth !== after.salvage.depth || before.salvage.top !== after.salvage.top) {
|
||||
out.push('salvage');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -437,6 +437,8 @@ export type Frame = {
|
||||
maxCollisionsPerDay: number;
|
||||
maxCollisionsTotal: number;
|
||||
collisionsToday: number;
|
||||
/** What the Day that just ended finished on — see `collisionsPrevDay` in `engine/state.ts`. */
|
||||
collisionsPrevDay: number;
|
||||
collisionsTotal: number;
|
||||
status: GameState['status'];
|
||||
outcome: GameState['outcome'];
|
||||
@@ -1594,6 +1596,7 @@ export function projectSharedTable(s: GameState) {
|
||||
maxCollisionsPerDay: s.config.maxCollisionsPerDay,
|
||||
maxCollisionsTotal: s.config.maxCollisionsTotal,
|
||||
collisionsToday: s.collisionsToday,
|
||||
collisionsPrevDay: s.collisionsPrevDay,
|
||||
collisionsTotal: s.collisionsTotal,
|
||||
status: s.status,
|
||||
outcome: s.outcome,
|
||||
|
||||
+74
-4
@@ -23,7 +23,7 @@
|
||||
* folding events does not rebuild a game — `protocol.md` §3.)
|
||||
*/
|
||||
|
||||
import { pump } from '../engine/advance.ts';
|
||||
import { advance, pump } from '../engine/advance.ts';
|
||||
import { applyIntent } from '../engine/apply.ts';
|
||||
import type { GameEvent } from '../engine/events.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
@@ -33,6 +33,8 @@ import type { CardId, GameConfig, GameState, PlayerIndex } from '../engine/state
|
||||
import { overHandLimit as overHandLimitOf } from '../engine/state.ts';
|
||||
import { playerAtSeat } from '../engine/state.ts';
|
||||
import { cuesFor, narrate } from '../sim/narrate.ts';
|
||||
import { collectStep, newCollector } from '../sim/display-step.ts';
|
||||
import type { DisplayCollector } from '../sim/display-step.ts';
|
||||
// Import from the view module, NOT replay.ts — replay.ts writes files and reads process.argv,
|
||||
// which would pull node:fs into a browser bundle.
|
||||
import {
|
||||
@@ -276,6 +278,16 @@ export type Game = {
|
||||
* having taken a turn to cause it.
|
||||
*/
|
||||
announced: string | null;
|
||||
/**
|
||||
* ORDERED PRESENTATION STEPS — v0.8.0, TODO #13. One per accepted intent, so a player can WATCH
|
||||
* what everyone else did rather than find the board already rearranged.
|
||||
*
|
||||
* Accumulated here beside `log`, `cues` and `announced` and drained the same way, because that is
|
||||
* how this file already hands things to whatever is displaying the game. Filled by `submit()`
|
||||
* alone, which is what makes it identical for solitaire and multiplayer and inert during replay —
|
||||
* see `sim/display-step.ts`.
|
||||
*/
|
||||
display: DisplayCollector;
|
||||
};
|
||||
|
||||
/** How each intent kind is introduced in the action list, in the order they should appear. */
|
||||
@@ -311,7 +323,7 @@ export const SOLO_PLAYER = 'Solitaire';
|
||||
|
||||
export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
|
||||
const state = createGame({ id: `web-${seed}`, seed, config, playerNames: [SOLO_PLAYER] });
|
||||
const game: Game = { state, seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null };
|
||||
const game: Game = { state, seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null, display: newCollector() };
|
||||
// A history that opens mid-Stage reads as though something was missed. Say what the game IS
|
||||
// first, then let the clock take over.
|
||||
game.log.push({ text: 'Game Begins', tone: 'start' });
|
||||
@@ -330,7 +342,7 @@ export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
|
||||
*/
|
||||
export function newMultiplayerGame(seed: number, config: GameConfig, playerNames: string[]): Game {
|
||||
const state = createGame({ id: `mp-${seed}`, seed, config, playerNames });
|
||||
const game: Game = { state, seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null };
|
||||
const game: Game = { state, seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null, display: newCollector() };
|
||||
game.log.push({ text: 'Game Begins', tone: 'start' });
|
||||
/**
|
||||
* NO SEED AT A TABLE WITH MORE THAN ONE SEAT (Gitea#20 step 1).
|
||||
@@ -1105,11 +1117,69 @@ export function submit(game: Game, intent: Intent, as: PlayerIndex | null = null
|
||||
return false;
|
||||
}
|
||||
game.history.push(intent);
|
||||
/**
|
||||
* THE HIGH-WATER MARK FOR THIS STEP'S NARRATION (v0.8.0).
|
||||
*
|
||||
* Taken here rather than read from `session.ts`'s `sentLines`, which is per-seat and is MUTATED
|
||||
* by `linesSince()` as a side effect of building a push — so it cannot answer "what did this one
|
||||
* intent say?". `submit` brackets the whole thing, `record` and `drain` below are the only things
|
||||
* that append, and the slice after them is exactly this intent's narration including whatever
|
||||
* automatic phases it drained.
|
||||
*/
|
||||
const saidFrom = game.log.length;
|
||||
record(game, result.events, actor);
|
||||
drain(game);
|
||||
collectStep(game.display, game.state, actor, intent.type, game.log.slice(saidFrom));
|
||||
drainStepping(game);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* `drain()`'s STEPPED TWIN — TODO #18, and the reason this is not just `drain(game)`.
|
||||
*
|
||||
* `pump()` runs every automatic phase between one click and the next and `drain()` records the whole
|
||||
* batch at once, so New Train, the Mainline and the shift change are never drawn at all: trains
|
||||
* cross the Division in a single jump. Stepping `advance()` one call at a time and collecting after
|
||||
* each is what gives those phases a visible beat, which is exactly what TODO Reference · #18 says is
|
||||
* needed — *"a minimum dwell time on its own therefore fixes nothing"*.
|
||||
*
|
||||
* IDENTICAL BEHAVIOUR TO `drain()`, deliberately. The same `advance()` calls in the same order
|
||||
* produce the same state; `record()` is called per phase rather than per batch, which is equivalent
|
||||
* because `cuesFor` is a pure per-event map with no cross-event state and `record`'s other outputs
|
||||
* (`scheduled`, `justDrawn`, `announced`) are last-wins in event order either way.
|
||||
*
|
||||
* A PHASE THAT DID NOTHING PRODUCES NO STEP. Jesse, 2026-09-09: *"if nothing happens during a phase
|
||||
* then we shouldn't lose time to it."* Narrating nothing is the test for that — an empty phase adds
|
||||
* no lines, so it is skipped rather than given a dwell to sit through.
|
||||
*
|
||||
* `drain()` itself is untouched, and must stay that way: `fromSave`, `fromMultiplayerSave` and
|
||||
* `undo` all use it, and the collector staying off those paths is what keeps a replay from
|
||||
* re-emitting a whole game as steps.
|
||||
*/
|
||||
function drainStepping(game: Game): void {
|
||||
for (let i = 0; i < 10_000; i++) {
|
||||
const from = game.log.length;
|
||||
const r = advance(game.state);
|
||||
record(game, r.events);
|
||||
/**
|
||||
* THE TEST IS THE EVENT LIST, NOT THE LOG — and getting that wrong drifted the board.
|
||||
*
|
||||
* `record()` deliberately drops `actorChanged` before narrating, so a phase whose only effect is
|
||||
* handing the turn to the next player grows no lines at all. Collecting only when the log grew
|
||||
* therefore skipped those, and the last step's frame was then a position behind the real one:
|
||||
* the animated board ended a turn out of step with the game (`actor: 2` where the game said 1).
|
||||
*
|
||||
* A step whose narration is empty still carries the board. It simply costs no time to show —
|
||||
* `dwellForStep` gives a silent step a dwell of zero — which is the same rule that collapses an
|
||||
* empty phase, arrived at from the other direction.
|
||||
*/
|
||||
if (r.events.length > 0) {
|
||||
collectStep(game.display, game.state, null, 'phase', game.log.slice(from));
|
||||
}
|
||||
if (r.needsInput || game.state.status === 'finished') return;
|
||||
}
|
||||
throw new Error('phase driver failed to settle — probable infinite loop');
|
||||
}
|
||||
|
||||
/**
|
||||
* Which cards in hand can be played RIGHT NOW, in hand order.
|
||||
*
|
||||
|
||||
+393
-61
@@ -24,6 +24,9 @@ import type { NewGameOptions } from './game.ts';
|
||||
import type { LocalSession, Session } from './session.ts';
|
||||
import { createLocalSession, createRemoteSession } from './session.ts';
|
||||
import type { PlayerIndex } from '../engine/state.ts';
|
||||
import type { PublicDistrict } from '../sim/view.ts';
|
||||
import { createStepQueue } from './step-queue.ts';
|
||||
import { PACE_LEVELS } from '../sim/pacing.ts';
|
||||
import { notice, prefillCode, runLobby } from './lobby.ts';
|
||||
import type { LobbyReady } from './lobby.ts';
|
||||
import {
|
||||
@@ -51,6 +54,7 @@ const REMOTE_KEY = 'station-master.remote.v1';
|
||||
/** Preset board zoom levels — a fraction applied to the rendered SVG's own pixel dimensions. */
|
||||
const ZOOM_LEVELS = [0.75, 1, 1.25, 1.5] as const;
|
||||
|
||||
|
||||
/**
|
||||
* Small persisted preferences, kept in a `localStorage` key of their own — separate from
|
||||
* `SAVE_KEY`. A save is the seed plus the intents and has to stay portable; none of this belongs
|
||||
@@ -67,9 +71,43 @@ type Settings = {
|
||||
* to?", which Jesse's own framing says is "not something they're likely to need all the time".
|
||||
*/
|
||||
gameCardOpen: boolean;
|
||||
/**
|
||||
* HOW FAST OTHER PEOPLE'S TURNS PLAY BACK — v0.8.0, TODO #13/#18. A multiplier over the dwell
|
||||
* table in `sim/pacing.ts`: 1 is as tabled, 0.5 is twice as fast, and **0 turns animation off**,
|
||||
* which is TODO #18's "a player who has seen it a hundred times will want it off" without a second
|
||||
* mechanism for it.
|
||||
*
|
||||
* Here rather than in the game's config, on Jesse's call 2026-09-09: dwell is presentation, not a
|
||||
* rule, and a `GameConfig` rides along in saves and replays. It is also per-viewer for the reason
|
||||
* this whole object exists — two players at one table may reasonably want different speeds.
|
||||
*/
|
||||
pace: number;
|
||||
};
|
||||
|
||||
const DEFAULT_SETTINGS: Settings = { districtMode: 'auto', soundOn: false, zoom: 1, gameCardOpen: false };
|
||||
const DEFAULT_SETTINGS: Settings = { districtMode: 'auto', soundOn: false, zoom: 1, gameCardOpen: false, pace: 1 };
|
||||
|
||||
/**
|
||||
* `?pace=` — a per-session override that persists nothing.
|
||||
*
|
||||
* The third of the three tuning levels the design calls for (`docs/plans/jitsi-common-board.md`
|
||||
* § v0.8.0 § 5): the committed table needs a rebuild, the setting needs a click, and this needs a
|
||||
* link — which is what makes it the one that is actually useful at a playtest, where two testers can
|
||||
* be handed different speeds and compared. Follows `?seed=`, which is already the convention here.
|
||||
*
|
||||
* Read ONCE, at load. The queue asks for the pace on every step it measures, and `behind()` asks for
|
||||
* every step still queued — so parsing the query string in there meant building a `URLSearchParams`
|
||||
* a hundred times to render one row. It cannot change without a reload anyway.
|
||||
*/
|
||||
const PACE_OVERRIDE: number | null = (() => {
|
||||
try {
|
||||
const raw = new URLSearchParams(location.search).get('pace');
|
||||
if (raw === null) return null;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) && n >= 0 ? n : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
function loadSettings(): Settings {
|
||||
try {
|
||||
@@ -87,6 +125,11 @@ function loadSettings(): Settings {
|
||||
: DEFAULT_SETTINGS.zoom,
|
||||
gameCardOpen:
|
||||
typeof parsed.gameCardOpen === 'boolean' ? parsed.gameCardOpen : DEFAULT_SETTINGS.gameCardOpen,
|
||||
// A negative or non-finite saved value is corrupt, not a request to run time backwards.
|
||||
pace:
|
||||
typeof parsed.pace === 'number' && Number.isFinite(parsed.pace) && parsed.pace >= 0
|
||||
? parsed.pace
|
||||
: DEFAULT_SETTINGS.pace,
|
||||
};
|
||||
} catch {
|
||||
// A full or disabled localStorage must not take the game down with it — same guard as the save.
|
||||
@@ -113,6 +156,206 @@ function saveSettings(patch: Partial<Settings>): void {
|
||||
*/
|
||||
let session: Session;
|
||||
|
||||
/**
|
||||
* THE ANIMATION QUEUE — v0.8.0, TODO #13/#15/#18.
|
||||
*
|
||||
* Holds the board the screen is showing, which is not always the board the game is on. One queue
|
||||
* for both session kinds: solitaire drains its own collector and a remote session reads the same
|
||||
* steps off the wire, and this cannot tell which it has (`web/step-queue.ts`).
|
||||
*
|
||||
* Reads `pace` through a function rather than a captured value, so changing the setting takes effect
|
||||
* on the next step instead of the next game. `?pace=` wins over the saved setting for this session
|
||||
* only.
|
||||
*/
|
||||
const stepQueue = createStepQueue(
|
||||
() => PACE_OVERRIDE ?? settings.pace,
|
||||
// Whose moves not to bother replaying — this client's own. Read lazily: `session` is assigned when
|
||||
// a game starts, long after this queue is built.
|
||||
() => (session ? session.seat() : null),
|
||||
);
|
||||
|
||||
/**
|
||||
* 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());
|
||||
/**
|
||||
* NOWHERE TO ANIMATE MEANS DO NOT QUEUE AT ALL.
|
||||
*
|
||||
* Without `requestAnimationFrame` nothing ever advances the queue, so `busy()` would stay true for
|
||||
* good — and since "Your Move" is now put away while the board is catching up, that would hide a
|
||||
* player's own actions permanently, leaving Skip as the only way to play the game. Drawing
|
||||
* everything at once is exactly what `pace = 0` does deliberately, so that is the honest fallback
|
||||
* rather than a broken page. Caught by `test/web.test.ts`, whose DOM stub has no `rAF` — the same
|
||||
* stub that has been proving this page still starts since long before any of this existed.
|
||||
*/
|
||||
if (typeof requestAnimationFrame !== 'function') {
|
||||
stepQueue.skip();
|
||||
return;
|
||||
}
|
||||
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(f?: Frame): void {
|
||||
const behind = stepQueue.behind();
|
||||
const row = $('watching');
|
||||
/**
|
||||
* VISIBLE WHILE THE BOARD IS BEHIND **OR** STILL SHOWING SOMETHING.
|
||||
*
|
||||
* It used to hide the moment `behind` hit zero — which is the moment the LAST step of a burst goes
|
||||
* up, so the one step a player was most likely to be reading about lost its caption. Collapsed
|
||||
* otherwise: in solitaire that is nearly always, and between turns in multiplayer too, and a row
|
||||
* that is always there is a row nobody reads.
|
||||
*/
|
||||
if (behind === 0 && !stepQueue.busy()) {
|
||||
row.hidden = true;
|
||||
return;
|
||||
}
|
||||
row.hidden = false;
|
||||
$('watching-behind').textContent = behind === 0 ? 'catching up' : `${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.
|
||||
*/
|
||||
/**
|
||||
* WHO, THEN WHAT — Jesse, 2026-09-09: *"it didn't tell me what the actual action was, like who I
|
||||
* was waiting on or what they were doing. I knew I was behind, but I wasn't sure what I was
|
||||
* supposed to be looking for."*
|
||||
*
|
||||
* The caption was there; it was the wrong half of the sentence. Half the waiting is automatic
|
||||
* phases, whose narration reads "Mainline" — accurate, and no answer at all to "who am I waiting
|
||||
* on". So the name goes first, and a phase says so in as many words rather than leaving the reader
|
||||
* to infer that nobody is acting.
|
||||
*
|
||||
* The narrated line is used as it stands otherwise, because `record()` already prefixes it with the
|
||||
* player — "Player Bot 1 moved Train 3 (−1,−2) → (−1,1)" — so a second name would stutter.
|
||||
*/
|
||||
const showing = stepQueue.showing();
|
||||
const said = showing?.lines[0]?.text ?? '';
|
||||
const who =
|
||||
showing === null || showing === undefined
|
||||
? ''
|
||||
: showing.player === null
|
||||
? 'The Division'
|
||||
: (f?.players[showing.player]?.name ?? `Seat ${seatLabel(showing.player)}`);
|
||||
// A player action already names its actor; a phase does not, so it is introduced.
|
||||
$('watching-what').textContent = showing?.player === null && said !== '' ? `${who}: ${said}` : said;
|
||||
/**
|
||||
* 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;
|
||||
// Do not strand the player behind a queue that can no longer advance: jump the board to the
|
||||
// live position, which brings "Your Move" back with it.
|
||||
try {
|
||||
stepQueue.skip();
|
||||
} catch {
|
||||
/* nothing further to try — the authoritative Frame is still what the rest of the page draws */
|
||||
}
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (!stepQueue.busy()) {
|
||||
animating = false;
|
||||
/**
|
||||
* A FULL RENDER, not just the row. The board being level again is what brings "Your Move"
|
||||
* back and clears the last lit pile, so redrawing only the catching-up row would leave the
|
||||
* action list hidden until something else happened to trigger a render — which, when the game
|
||||
* is waiting on this player, is nothing at all.
|
||||
*/
|
||||
render();
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
/**
|
||||
* The three `Capabilities` (`undo`/`saveLocal`/`newGame`) travel together — all `true` for a
|
||||
* `LocalSession`, all `false` for a `RemoteSession` (`session.ts`) — so any one of them is a safe
|
||||
@@ -763,7 +1006,8 @@ function beginRemote(ready: LobbyReady, rejoining = false): void {
|
||||
// real Frame only exists once the SSE connection's first push arrives, so the first render waits
|
||||
// for `subscribe`'s callback rather than firing immediately (`session.ts`'s own doc comment on
|
||||
// `createRemoteSession` explains why `view()` would otherwise throw).
|
||||
session.subscribe(render);
|
||||
// Steps are pulled in BEFORE the redraw, so a push and the animation it starts land together.
|
||||
session.subscribe(() => { drainIntoQueue(); render(); });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -907,7 +1151,8 @@ function start(): void {
|
||||
applyCapabilities();
|
||||
// Every render goes through the session, so the page redraws whenever the game says it changed —
|
||||
// which is what a remote session will use to push. Locally it fires on each accepted intent.
|
||||
session.subscribe(render);
|
||||
// Steps are pulled in BEFORE the redraw, so a push and the animation it starts land together.
|
||||
session.subscribe(() => { drainIntoQueue(); render(); });
|
||||
render();
|
||||
// Coming back to a game is not the same event as being dealt one, and the board looks identical
|
||||
// either way — mid-Day, mid-phase, with a log already deep (Jesse, 2026-08-30).
|
||||
@@ -1073,6 +1318,7 @@ function render(): void {
|
||||
|
||||
renderTurnChart(f);
|
||||
renderPresence(f);
|
||||
renderWatching(f);
|
||||
$('revenue').textContent = String(f.revenue);
|
||||
/**
|
||||
* THE OBJECTIVE, WITHOUT THE COMMENTARY.
|
||||
@@ -1139,68 +1385,83 @@ function render(): void {
|
||||
: 'ATTACH TO THIS CARD';
|
||||
return [{ row: cell.row, col: cell.col, label }];
|
||||
});
|
||||
grid.innerHTML = officeSvg(f.cells, f.runningRow, ghostCoords, legalCaps, f.limits, selectedCrew);
|
||||
/**
|
||||
* SOMEBODY ELSE'S BOARD IS READ-ONLY, and that is not a cosmetic distinction.
|
||||
*
|
||||
* No ghosts, no legal caps and no selected crew: all three are answers to "what could YOU do
|
||||
* here", computed from this seat's own menu, and drawing them over another player's district
|
||||
* would offer moves on a board you cannot play. Every click handler below is skipped for the same
|
||||
* reason — `spotsAt` holds coordinates in YOUR district, and the same coordinates exist in theirs,
|
||||
* so wiring them up would silently attach your moves to their squares.
|
||||
*/
|
||||
const watched = watchedDistrict(f);
|
||||
$('districtwho').textContent = watched ? `${watched.name}'s Office Area` : 'Your Office Area';
|
||||
grid.innerHTML = watched
|
||||
? officeSvg(watched.cells, watched.runningRow, [], [], watched.limits, null)
|
||||
: officeSvg(f.cells, f.runningRow, ghostCoords, legalCaps, f.limits, selectedCrew);
|
||||
applyZoom(grid);
|
||||
|
||||
// Wire the roster chips: clicking one sets `selectedCrew`, the same value the "Which train are
|
||||
// you switching?" picker writes, so the board and the action panel drive one value either way.
|
||||
for (const g of Array.from(grid.querySelectorAll('g[data-crew]'))) {
|
||||
const trayId = (g as HTMLElement).dataset['crew'];
|
||||
if (trayId) (g as unknown as HTMLElement).onclick = () => { selectedCrew = trayId; render(); };
|
||||
}
|
||||
|
||||
// Highlighting rides on top of the drawing: outline the legal squares and make them clickable.
|
||||
for (const [key, list] of spotsAt) {
|
||||
const [gr, gc] = key.split(',').map(Number);
|
||||
const g = grid.querySelector(`g[data-cell="${gr},${gc}"]`);
|
||||
if (g) {
|
||||
g.classList.add('bs-legal');
|
||||
(g as unknown as HTMLElement).onclick = () => pick(key, list);
|
||||
if (!watched) {
|
||||
// Wire the roster chips: clicking one sets `selectedCrew`, the same value the "Which train are
|
||||
// you switching?" picker writes, so the board and the action panel drive one value either way.
|
||||
for (const g of Array.from(grid.querySelectorAll('g[data-crew]'))) {
|
||||
const trayId = (g as HTMLElement).dataset['crew'];
|
||||
if (trayId) (g as unknown as HTMLElement).onclick = () => { selectedCrew = trayId; render(); };
|
||||
}
|
||||
}
|
||||
// Wire the targets. They are already in the SVG, so nothing is re-serialised here.
|
||||
for (const [key, list] of spotsAt) {
|
||||
if (f.cells.some((c) => `${c.row},${c.col}` === key)) continue;
|
||||
const g = grid.querySelector(`g[data-ghost="${key}"]`);
|
||||
if (g) (g as unknown as HTMLElement).onclick = () => pick(key, list);
|
||||
}
|
||||
|
||||
/**
|
||||
* THE SWITCHING MOVE, ON THE BOARD.
|
||||
*
|
||||
* Every switching decision is about geography — which card the crew can reach, what it will couple
|
||||
* on the way, whether it can get back — and none of it was drawn: the moves were text buttons
|
||||
* reading "move to (0, -2)". The classes and the replay viewer have highlighted a position for
|
||||
* months; the play page simply never used them.
|
||||
*
|
||||
* WHERE IT IS, WHERE IT MAY GO, AND WHY NOT THE REST. A blocked card carries its reason in its own
|
||||
* tooltip, so "why can I not get into that industry?" is answered by hovering the industry.
|
||||
*/
|
||||
/**
|
||||
* ONE CREW'S SQUARES AT A TIME. Drawing every crew's reachable squares at once is worse than
|
||||
* drawing one crew's: the highlights merge into a single blob and stop meaning "here is where
|
||||
* THIS train can go", which is the whole reason they are on the board.
|
||||
*/
|
||||
const crew = pickedCrew(f);
|
||||
if (crew && !forPlay) {
|
||||
// Marked on the crew strip, not the whole card: the Office is the one square a second train may
|
||||
// share, and outlining the card would claim it belongs to both.
|
||||
const strip = grid.querySelector(`g[data-cell="${crew.from.row},${crew.from.col}"] .bs-crew`);
|
||||
if (strip) strip.classList.add('bs-from');
|
||||
for (const c of crew.to) {
|
||||
const g = grid.querySelector(`g[data-cell="${c.row},${c.col}"]`);
|
||||
if (g) g.classList.add('bs-focus');
|
||||
// Highlighting rides on top of the drawing: outline the legal squares and make them clickable.
|
||||
for (const [key, list] of spotsAt) {
|
||||
const [gr, gc] = key.split(',').map(Number);
|
||||
const g = grid.querySelector(`g[data-cell="${gr},${gc}"]`);
|
||||
if (g) {
|
||||
g.classList.add('bs-legal');
|
||||
(g as unknown as HTMLElement).onclick = () => pick(key, list);
|
||||
}
|
||||
}
|
||||
for (const b of crew.blocked) {
|
||||
const g = grid.querySelector(`g[data-cell="${b.coord.row},${b.coord.col}"]`);
|
||||
if (!g) continue;
|
||||
// Two different things wearing two different marks. A turnout you cannot STOP on is not in
|
||||
// your way — you run through it — so it must not be drawn like an industry that is locked.
|
||||
const passable = b.kind === 'noStopping';
|
||||
g.classList.add(passable ? 'bs-nostop' : 'bs-blocked');
|
||||
const own = g.getAttribute('data-tip') ?? '';
|
||||
const head = passable ? 'NO STOPPING HERE' : 'THE CREW CANNOT MOVE HERE';
|
||||
g.setAttribute('data-tip', `${own}\n\n${head}: ${b.why}`);
|
||||
// Wire the targets. They are already in the SVG, so nothing is re-serialised here.
|
||||
for (const [key, list] of spotsAt) {
|
||||
if (f.cells.some((c) => `${c.row},${c.col}` === key)) continue;
|
||||
const g = grid.querySelector(`g[data-ghost="${key}"]`);
|
||||
if (g) (g as unknown as HTMLElement).onclick = () => pick(key, list);
|
||||
}
|
||||
|
||||
/**
|
||||
* THE SWITCHING MOVE, ON THE BOARD.
|
||||
*
|
||||
* Every switching decision is about geography — which card the crew can reach, what it will couple
|
||||
* on the way, whether it can get back — and none of it was drawn: the moves were text buttons
|
||||
* reading "move to (0, -2)". The classes and the replay viewer have highlighted a position for
|
||||
* months; the play page simply never used them.
|
||||
*
|
||||
* WHERE IT IS, WHERE IT MAY GO, AND WHY NOT THE REST. A blocked card carries its reason in its own
|
||||
* tooltip, so "why can I not get into that industry?" is answered by hovering the industry.
|
||||
*/
|
||||
/**
|
||||
* ONE CREW'S SQUARES AT A TIME. Drawing every crew's reachable squares at once is worse than
|
||||
* drawing one crew's: the highlights merge into a single blob and stop meaning "here is where
|
||||
* THIS train can go", which is the whole reason they are on the board.
|
||||
*/
|
||||
const crew = pickedCrew(f);
|
||||
if (crew && !forPlay) {
|
||||
// Marked on the crew strip, not the whole card: the Office is the one square a second train may
|
||||
// share, and outlining the card would claim it belongs to both.
|
||||
const strip = grid.querySelector(`g[data-cell="${crew.from.row},${crew.from.col}"] .bs-crew`);
|
||||
if (strip) strip.classList.add('bs-from');
|
||||
for (const c of crew.to) {
|
||||
const g = grid.querySelector(`g[data-cell="${c.row},${c.col}"]`);
|
||||
if (g) g.classList.add('bs-focus');
|
||||
}
|
||||
for (const b of crew.blocked) {
|
||||
const g = grid.querySelector(`g[data-cell="${b.coord.row},${b.coord.col}"]`);
|
||||
if (!g) continue;
|
||||
// Two different things wearing two different marks. A turnout you cannot STOP on is not in
|
||||
// your way — you run through it — so it must not be drawn like an industry that is locked.
|
||||
const passable = b.kind === 'noStopping';
|
||||
g.classList.add(passable ? 'bs-nostop' : 'bs-blocked');
|
||||
const own = g.getAttribute('data-tip') ?? '';
|
||||
const head = passable ? 'NO STOPPING HERE' : 'THE CREW CANNOT MOVE HERE';
|
||||
g.setAttribute('data-tip', `${own}\n\n${head}: ${b.why}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1277,7 +1538,9 @@ function render(): void {
|
||||
* reach. Drawn like the hand so they read as cards, dashed and unlit because taking one is a draw
|
||||
* action rather than a click on the card itself.
|
||||
*/
|
||||
$('depts').innerHTML = pilesHtml(f);
|
||||
// The pile the move being WATCHED just touched, lit for as long as that step is on screen. Empty
|
||||
// whenever the board is level with the game, or when the move was this player's own.
|
||||
$('depts').innerHTML = pilesHtml(f, stepQueue.busy() ? stepQueue.lit() : []);
|
||||
|
||||
renderYards(f);
|
||||
|
||||
@@ -1751,6 +2014,29 @@ function renderActions(
|
||||
renderEnding(el, f);
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* YOUR MOVE IS PUT AWAY WHILE THE BOARD IS CATCHING UP — Jesse, 2026-09-10: *"your actions should
|
||||
* be hidden while catching up."*
|
||||
*
|
||||
* Two reasons, and the second is the one that changed my mind about it. The board on screen is
|
||||
* behind the game, so a move offered here is a move against a position that has already moved on —
|
||||
* the menu is computed from the CURRENT state and would be acted on while looking at an older one.
|
||||
* And the display had grown to four things demanding attention at once — the district, the history,
|
||||
* the catching-up row and now a lit pile — which is what made the pile highlight so easy to miss.
|
||||
* Taking the action list out of that competition while there is nothing to decide anyway is the
|
||||
* cheapest way to quieten it.
|
||||
*
|
||||
* NOT A BLOCK. Skip is one click away and sits at the left of the row, so the wait is always
|
||||
* voluntary; this replaces the buttons with the reason they are gone, rather than leaving a live
|
||||
* menu over a stale board.
|
||||
*/
|
||||
if (stepQueue.busy()) {
|
||||
el.innerHTML =
|
||||
'<div class="dim">Catching up on what everyone else did — your move is here when the board is ' +
|
||||
'level with the game. <b>Skip</b> jumps straight to it.</div>';
|
||||
return;
|
||||
}
|
||||
// The game is running, so the next ending — an extended Day's, or a fresh game's — is entitled to
|
||||
// put its results up unasked again (Gitea#11).
|
||||
resultsShown = false;
|
||||
@@ -2398,6 +2684,52 @@ function runSolitaireSetup(params: URLSearchParams, hasSave = false, live: Frame
|
||||
dealBtn.onclick = () => commitNewGame(ss, seedField?.value ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* PLAYBACK SPEED — v0.8.0.3, TODO #13.
|
||||
*
|
||||
* Persisted per viewer in `Settings`, so it survives the navigation that was eating `?pace=`. The
|
||||
* queue reads `settings.pace` through a closure on every step, so a change here takes effect on the
|
||||
* very next move rather than the next game.
|
||||
*/
|
||||
const paceSlowerBtn = document.getElementById('paceslower') as HTMLButtonElement | null;
|
||||
const paceFasterBtn = document.getElementById('pacefaster') as HTMLButtonElement | null;
|
||||
const paceLabel = document.getElementById('pacelabel');
|
||||
if (paceSlowerBtn && paceFasterBtn && paceLabel) {
|
||||
const nearestPace = (): number => {
|
||||
// A saved or URL value need not be on the ladder — `?pace=7` and a hand-edited setting are both
|
||||
// legitimate — so the buttons step from whichever preset is closest rather than refusing to move.
|
||||
const want = PACE_OVERRIDE ?? settings.pace;
|
||||
return PACE_LEVELS.reduce((best, p) => (Math.abs(p - want) < Math.abs(best - want) ? p : best), PACE_LEVELS[0]);
|
||||
};
|
||||
const paintPace = (): void => {
|
||||
const p = PACE_OVERRIDE ?? settings.pace;
|
||||
paceLabel.textContent = p === 0 ? 'off' : `${p}×`;
|
||||
const i = PACE_LEVELS.indexOf(nearestPace() as (typeof PACE_LEVELS)[number]);
|
||||
paceSlowerBtn.disabled = i >= PACE_LEVELS.length - 1;
|
||||
paceFasterBtn.disabled = i <= 0;
|
||||
// A `?pace=` in the URL wins over the setting, so say so rather than showing dead buttons.
|
||||
if (PACE_OVERRIDE !== null) {
|
||||
paceSlowerBtn.disabled = true;
|
||||
paceFasterBtn.disabled = true;
|
||||
paceLabel.textContent = `${PACE_OVERRIDE}× (URL)`;
|
||||
}
|
||||
};
|
||||
const stepPace = (by: number): void => {
|
||||
const i = PACE_LEVELS.indexOf(nearestPace() as (typeof PACE_LEVELS)[number]);
|
||||
const next = PACE_LEVELS[Math.min(PACE_LEVELS.length - 1, Math.max(0, i + by))];
|
||||
if (next === undefined) return;
|
||||
saveSettings({ pace: next });
|
||||
paintPace();
|
||||
// The row's countdown is measured in steps that will dwell, so a change to 0 empties it at once.
|
||||
renderWatching();
|
||||
};
|
||||
// Slower is a BIGGER multiplier, so "−" walks up the ladder. Labelled by what it does to the game,
|
||||
// not to the number: a player pressing "slower" wants to watch for longer.
|
||||
paceSlowerBtn.onclick = () => stepPace(1);
|
||||
paceFasterBtn.onclick = () => stepPace(-1);
|
||||
paintPace();
|
||||
}
|
||||
|
||||
const zoomOutBtn = document.getElementById('zoomout') as HTMLButtonElement | null;
|
||||
const zoomInBtn = document.getElementById('zoomin') as HTMLButtonElement | null;
|
||||
const zoomLabel = document.getElementById('zoomlabel');
|
||||
|
||||
+84
-8
@@ -52,25 +52,61 @@ export function handHtml(f: Frame, canPlay: (boolean | null)[] = []): string {
|
||||
* Only the top card may ever be drawn, so the depth is a count and not a hint: everything below it
|
||||
* is out of reach, and choosing where to discard is choosing what to put there.
|
||||
*/
|
||||
export function pilesHtml(f: Frame): string {
|
||||
const pile = (label: string, top: string, depth: number, why: string, extra = '', slot = -1): string => {
|
||||
export function pilesHtml(f: Frame, lit: readonly string[] = []): string {
|
||||
const pile = (
|
||||
key: string,
|
||||
label: string,
|
||||
top: string,
|
||||
depth: number,
|
||||
why: string,
|
||||
extra = '',
|
||||
slot = -1,
|
||||
faceDown = false,
|
||||
): string => {
|
||||
const tip = [why, extra].filter(Boolean).join(' · ');
|
||||
// A Department is a DROP TARGET for a discard. The attribute is always emitted; only the play
|
||||
// page binds a click to it, and only while a card is waiting to be discarded — so the replay
|
||||
// viewer draws exactly the same markup and nothing there is clickable.
|
||||
const target = slot >= 0 ? ` data-dept="${slot}"` : '';
|
||||
// `lit` marks the pile the move being watched just touched — see `changedPiles`.
|
||||
const cls = `handcard${faceDown ? ' facedown' : ''}${lit.includes(key) ? ' pilelit' : ''}`;
|
||||
return (
|
||||
`<div class="handcard"${target}${tip ? ` data-tip="${esc(tip)}"` : ''} tabindex="0">` +
|
||||
`<div class="${cls}"${target}${tip ? ` data-tip="${esc(tip)}"` : ''} tabindex="0">` +
|
||||
`<div class="pilehd"><span>${esc(label)}</span><span class="depth">${depth}</span></div>` +
|
||||
`<b>${esc(top)}</b></div>`
|
||||
);
|
||||
};
|
||||
return (
|
||||
/**
|
||||
* THE HOME OFFICE DECK, which the screen had never drawn.
|
||||
*
|
||||
* `f.deck` has carried the face-down count since the Frame existed and nothing read it — the
|
||||
* exact shape of display gap `test/display-gaps.test.ts` was written to sweep for, surviving in
|
||||
* the panel that draws every OTHER pile. Asked for by Jesse 2026-09-10 for a second reason: a
|
||||
* player drawing from it is the commonest move nobody can see, so it needs somewhere to flash.
|
||||
*
|
||||
* FIRST, because that is the order a card travels: out of here, into a hand, then onto a
|
||||
* Department or the Salvage Yard. Face down, so the card slot says so rather than naming a card
|
||||
* — the whole point of this pile is that nobody knows what is on top.
|
||||
*/
|
||||
pile(
|
||||
'home',
|
||||
'Home Office',
|
||||
'face down',
|
||||
f.deck,
|
||||
'The draw deck. Face down — nobody sees what is on top, and a card drawn from here is private ' +
|
||||
'to whoever drew it. When it runs out, the Salvage Yard and the Departments are swept back ' +
|
||||
'into it.',
|
||||
'',
|
||||
-1,
|
||||
true,
|
||||
) +
|
||||
f.departments
|
||||
.map((d, i) => {
|
||||
const depth = f.departmentDepth[i] ?? 0;
|
||||
const under = depth - 1;
|
||||
return pile(
|
||||
`dept${i}`,
|
||||
`Dept ${i + 1}`,
|
||||
d,
|
||||
depth,
|
||||
@@ -81,6 +117,7 @@ export function pilesHtml(f: Frame): string {
|
||||
})
|
||||
.join('') +
|
||||
pile(
|
||||
'salvage',
|
||||
'Salvage',
|
||||
f.salvage.top,
|
||||
f.salvage.depth,
|
||||
@@ -180,7 +217,7 @@ export function dayEndHtml(f: Frame): string {
|
||||
ahead +
|
||||
standingsHtml(f) +
|
||||
targetHtml(f) +
|
||||
collisionsHtml(f)
|
||||
collisionsHtml(f, ended)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -238,13 +275,28 @@ function targetHtml(f: Frame): string {
|
||||
* its config and enforces neither, so reporting a collision budget there would put a rule on
|
||||
* screen that this game does not have.
|
||||
*/
|
||||
function collisionsHtml(f: Frame): string {
|
||||
function collisionsHtml(f: Frame, endedDay?: number): string {
|
||||
const scoredOnCollisions =
|
||||
(f.mode === 'competitive' || f.mode === 'coop') &&
|
||||
(f.maxCollisionsTotal > 0 || f.maxCollisionsPerDay > 0);
|
||||
return scoredOnCollisions
|
||||
? `<p>Collisions: <b>${f.collisionsToday}</b> today, <b>${f.collisionsTotal}</b> in all.</p>`
|
||||
: '';
|
||||
if (!scoredOnCollisions) return '';
|
||||
/**
|
||||
* "TODAY" IS THE WRONG WORD IN A DAY-END DIALOG, and it read as a contradiction.
|
||||
*
|
||||
* That dialog is drawn from the frame whose `day` went UP — which is the same frame in which
|
||||
* `collisionsToday` was reset — so it reported 0 however many there had been. Jesse, 2026-09-09,
|
||||
* at the end of a Day 1 with two collisions in it: "it shows a total of two collisions, but zero
|
||||
* today ... that does seem to be a contradiction."
|
||||
*
|
||||
* So when the caller knows which Day just ended it says so by name, and reads the count captured at
|
||||
* the rollover. The end-of-game results screen passes nothing and keeps "today", where the Day has
|
||||
* not turned over and the word is accurate.
|
||||
*/
|
||||
const [count, when] =
|
||||
endedDay === undefined
|
||||
? [f.collisionsToday, 'today']
|
||||
: [f.collisionsPrevDay, `on Day ${endedDay}`];
|
||||
return `<p>Collisions: <b>${count}</b> ${when}, <b>${f.collisionsTotal}</b> in all.</p>`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -661,6 +713,30 @@ h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:#8b94a3;ma
|
||||
.handcard:focus{outline:2px solid #4d6fa8;outline-offset:1px}
|
||||
.cardrow.ref .handcard{background:#1c2129;border-style:dashed;border-color:#39424e;color:#b6bec9}
|
||||
.handcard.unplayable{color:#7d8794;border-color:#39424e}
|
||||
/* THE HOME OFFICE DECK. Face down, so its card slot names no card — it says so instead, in the
|
||||
dimmed voice the rest of the panel uses for "nothing to read here". */
|
||||
.handcard.facedown > b{color:#6f7885;font-style:italic;font-weight:400}
|
||||
/* THE PILE A WATCHED MOVE JUST TOUCHED (v0.8.1).
|
||||
A STATE, NOT A FLASH, and that is the whole point. The .tt-slot.fresh rule above animates for a fixed
|
||||
1.5s, which is right for a die roll nobody is waiting on — but a step can hold the screen for
|
||||
seven seconds at 10x, so a fixed animation would be over long before the pause it belongs to and
|
||||
the player would be back to staring at an unchanged board. The flash-in marks the moment; the lit
|
||||
border and background stay for exactly as long as the step is up, because the class is on the
|
||||
element only while that step is the one being shown. */
|
||||
.handcard.pilelit{border-color:#8fd6a0;background:#1d3327;box-shadow:0 0 0 2px #2f6b47,0 0 14px rgba(143,214,160,.55);
|
||||
animation:pilepulse 1.15s ease-in-out infinite}
|
||||
/* A PULSE FOR THE WHOLE DWELL, not one flash at the start. Measured: at 10x a pile stays lit for
|
||||
just under seven seconds, so the highlight was never brief — but a single 0.45s flash-in and a
|
||||
dark green fill were easy to miss entirely while watching the district. Jesse: "caught one flash
|
||||
deck light up for just a very brief moment, but couldn't see that with what bot was doing in
|
||||
office area and history and catch up area all at same time." Something still moving keeps drawing
|
||||
the eye for as long as the move is up; a state that settles stops asking to be looked at. */
|
||||
@keyframes pilepulse{0%,100%{background:#1d3327;box-shadow:0 0 0 2px #2f6b47,0 0 14px rgba(143,214,160,.45)}
|
||||
50%{background:#2f6b47;box-shadow:0 0 0 3px #8fd6a0,0 0 22px rgba(143,214,160,.85)}}
|
||||
/* Motion is the point here, so the reduced-motion fallback has to be loud in a different way rather
|
||||
than simply not moving: a solid ring and a brighter fill, held. */
|
||||
@media(prefers-reduced-motion:reduce){
|
||||
.handcard.pilelit{animation:none;background:#2f6b47;box-shadow:0 0 0 3px #8fd6a0}}
|
||||
.handcard.unplayable::after{content:"";position:absolute;inset:0;border-radius:5px;pointer-events:none;
|
||||
background:repeating-linear-gradient(45deg,transparent 0 5px,rgba(150,160,175,.20) 5px 6px)}
|
||||
/* THE CARD JUST DRAWN. It sits first in the row, and this says which one that is — three cards that
|
||||
|
||||
+34
-1
@@ -114,6 +114,18 @@ section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
|
||||
.lb-seat:last-child{border-bottom:none}
|
||||
.lb-seat .who{flex:1}
|
||||
#presence{color:#e0b060;font-size:12px;padding:0 14px;empty-cells:hide}
|
||||
/* WHAT YOU ARE WATCHING — v0.8.0, TODO #13/#15. An IN-FLOW row rather than a floating banner like
|
||||
#phasenote and #announce: those announce a moment and fade, this one stands for as long as the
|
||||
board is behind and has a button you have to be able to hit. Amber on the button because amber
|
||||
already means clickable everywhere else on this page; the row itself stays quiet so it does not
|
||||
compete with the three banners it sits under. */
|
||||
#watching{display:flex;align-items:center;gap:10px;padding:4px 14px;font-size:12px;color:#9aa0b4}
|
||||
#watching[hidden]{display:none}
|
||||
#watching-what{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.wbehind{font-variant-numeric:tabular-nums;font-weight:700;color:#c9cee0;
|
||||
background:#22263a;border:1px solid #343a52;border-radius:10px;padding:1px 8px;white-space:nowrap}
|
||||
#watching-skip{font-size:11px;padding:2px 10px;border-color:#e0b060;color:#e0b060;flex:none}
|
||||
#watching-who{color:#c9cee0;font-weight:700}
|
||||
#presence:empty{display:none}
|
||||
/* division strip */
|
||||
#division{display:flex;gap:7px;overflow-x:auto;padding-bottom:4px}
|
||||
@@ -861,6 +873,14 @@ ul.blocked li{padding:2px 0}
|
||||
<span class="zoom" title="Zoom the Division map and your Office Area. Both already scroll — this only changes their size.">
|
||||
<button id="zoomout" aria-label="Zoom out">−</button><span id="zoomlabel">100%</span><button id="zoomin" aria-label="Zoom in">+</button>
|
||||
</span>
|
||||
<!-- HOW FAST OTHER PLAYERS' TURNS PLAY BACK — v0.8.0.3, TODO #13.
|
||||
A CONTROL RATHER THAN ONLY A URL PARAMETER. `?pace=` shipped first and is unreachable through
|
||||
the front door: `index.html`'s two doors are `play.html?lobby` and `play.html?solitaire`, so
|
||||
arriving from the splash REPLACES the query string and any pace with it. Jesse played a whole
|
||||
game believing he was at 7x when he was at 1x. -->
|
||||
<span class="zoom" title="How long another player's or a bot's move is held on screen before the next one. Yours are never delayed. Off draws every move at once, as it did before v0.8.0.">
|
||||
<button id="paceslower" aria-label="Slower playback">−</button><span id="pacelabel">1×</span><button id="pacefaster" aria-label="Faster playback">+</button>
|
||||
</span>
|
||||
<button id="undo" title="Take the last action back. The save is the seed plus the moves made, so this replays the game without the last one — as far back as you like.">Undo</button>
|
||||
<button id="savefile" title="Download this game as a save file you can replay or share">Save replay</button>
|
||||
<button id="newgame" title="Set up a fresh game — the seed, the table, the opening hand and what the three economies pay. Opens the same screen a new solitaire game starts from, with your current rules filled in; your game in progress is kept until you press Deal, and Continue puts it straight back.">New game</button>
|
||||
@@ -890,12 +910,25 @@ ul.blocked li{padding:2px 0}
|
||||
collapsed whenever everyone connected is still connected — a `LocalSession` never fills it. -->
|
||||
<div id="presence"></div>
|
||||
|
||||
<!-- WHAT YOU ARE WATCHING, and how far behind the board is — v0.8.0, TODO #13/#15.
|
||||
One row rather than three additions: the countdown, the caption naming the action being shown,
|
||||
and Skip. Empty and collapsed whenever the board is level with the game, which in solitaire is
|
||||
almost always. -->
|
||||
<div id="watching" hidden>
|
||||
<!-- SKIP FIRST, on the left. It sat on the far right and a player's eye is on the countdown, not at
|
||||
the other end of the row — Jesse, 2026-09-09: "the skip button should be on the far left, in
|
||||
front of where it says [the count], so it's always close to where people are looking." -->
|
||||
<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>
|
||||
<span id="watching-behind" class="wbehind"></span>
|
||||
<span id="watching-what"></span>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<div>
|
||||
<section><h2>The Division — west to east</h2><div id="division"></div>
|
||||
<p class="ng-note" id="seating-chain"></p></section>
|
||||
<section id="district">
|
||||
<h2>Your Office Area
|
||||
<h2><span id="districtwho">Your Office Area</span>
|
||||
<span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span>
|
||||
<span id="districttoggle" class="seg" role="group" aria-label="When to show your Office Area"><button id="dm-auto" class="ghost" type="button" title="Open during Local Operations and Cargo — the phases that change the district — and folded otherwise.">Auto-hide</button><button id="dm-open" class="ghost" type="button" title="Keep the Office Area open in every phase.">Always show</button><button id="dm-closed" class="ghost" type="button" title="Keep the Office Area folded in every phase. The summary line stays, so it reads as folded rather than missing.">Always hide</button></span>
|
||||
</h2>
|
||||
|
||||
+70
-1
@@ -16,7 +16,10 @@
|
||||
*/
|
||||
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import type { Frame } from '../sim/view.ts';
|
||||
import type { Frame, PublicFrame } from '../sim/view.ts';
|
||||
import { publicSnapshot } from '../sim/view.ts';
|
||||
import { takeSteps } from '../sim/display-step.ts';
|
||||
import type { DisplayStep } from '../sim/display-step.ts';
|
||||
import type { PlayerIndex } from '../engine/state.ts';
|
||||
import { applyDelta } from '../sim/frame-delta.ts';
|
||||
import type { FrameDelta } from '../sim/frame-delta.ts';
|
||||
@@ -105,6 +108,28 @@ export type Session = {
|
||||
* starts — which is exactly when "is everyone here?" is the question.
|
||||
*/
|
||||
presence(): { seat: PlayerIndex; connected: boolean; seen: boolean }[];
|
||||
/**
|
||||
* ORDERED PRESENTATION STEPS — v0.8.0, TODO #13. What everyone else did, in order, so it can be
|
||||
* WATCHED rather than discovered.
|
||||
*
|
||||
* On the interface rather than on `LocalSession`, which is the whole point: solitaire drains its
|
||||
* own collector and a remote session reads the same steps off `Push.steps`, so the page animates
|
||||
* one queue and cannot tell which it has. That is what makes TODO #18 (solitaire's phases flying
|
||||
* past) and TODO #13 (multiplayer's invisible turns) the same code path.
|
||||
*
|
||||
* NOT `steps()` — `LocalSession.steps()` already exists and counts submitted intents for the Undo
|
||||
* button. Different thing entirely, hence the longer name.
|
||||
*/
|
||||
takeDisplaySteps(): DisplayStep[];
|
||||
/**
|
||||
* A public frame to start the queue from, once — draining, and non-null only when the queue must
|
||||
* be RESET rather than advanced.
|
||||
*
|
||||
* Steps carry deltas against a chain, so a client with no baseline cannot merge the next one. That
|
||||
* happens on a first connect, on a reconnect, and locally after an undo or a restore — all of
|
||||
* which rebuild from scratch. A reset means "throw away what is queued and draw this".
|
||||
*/
|
||||
takeDisplayReset(): PublicFrame | null;
|
||||
/**
|
||||
* Stop listening, for good.
|
||||
*
|
||||
@@ -145,6 +170,12 @@ export type LocalSession = Session & {
|
||||
*/
|
||||
export function createLocalSession(seed: number, options?: NewGameOptions): LocalSession {
|
||||
let game: Game = options ? newGame(seed, configWith(options)) : newGame(seed);
|
||||
/**
|
||||
* The baseline the step queue starts from. Set here, and again whenever the game is REPLACED —
|
||||
* `undo` and `restore` rebuild by replaying history, which (by design) collects no steps, so the
|
||||
* queue has to be told to start over rather than left holding a chain that no longer continues.
|
||||
*/
|
||||
let pendingReset: PublicFrame | null = publicSnapshot(game.state);
|
||||
const listeners = new Set<() => void>();
|
||||
const changed = (): void => {
|
||||
for (const fn of [...listeners]) fn();
|
||||
@@ -186,6 +217,14 @@ export function createLocalSession(seed: number, options?: NewGameOptions): Loca
|
||||
},
|
||||
justDrawn: () => game.justDrawn,
|
||||
presence: () => [],
|
||||
// Solitaire's own steps, from the same collector `submit()` fills for every seat of a
|
||||
// multiplayer game. No separate code path — see `sim/display-step.ts`.
|
||||
takeDisplaySteps: () => takeSteps(game.display),
|
||||
takeDisplayReset: () => {
|
||||
const reset = pendingReset;
|
||||
pendingReset = null;
|
||||
return reset;
|
||||
},
|
||||
|
||||
seed: () => game.seed,
|
||||
save: () => toSave(game),
|
||||
@@ -199,6 +238,8 @@ export function createLocalSession(seed: number, options?: NewGameOptions): Loca
|
||||
back.scheduled = null;
|
||||
back.justDrawn = null;
|
||||
back.announced = null;
|
||||
// The rebuilt game has an empty collector and a chain that starts over, so the queue must too.
|
||||
pendingReset = publicSnapshot(back.state);
|
||||
changed();
|
||||
return true;
|
||||
},
|
||||
@@ -207,6 +248,7 @@ export function createLocalSession(seed: number, options?: NewGameOptions): Loca
|
||||
// Restoring replays the whole history and re-records every draw; none of it is news.
|
||||
game.justDrawn = null;
|
||||
game.announced = null;
|
||||
pendingReset = publicSnapshot(game.state);
|
||||
changed();
|
||||
},
|
||||
};
|
||||
@@ -220,6 +262,10 @@ type Push = {
|
||||
lines: { text: string; tone: string }[];
|
||||
/** One entry for a change; every other seat at once on the connect push. */
|
||||
presence?: { seat: PlayerIndex; connected: boolean; seen: boolean }[];
|
||||
/** Ordered presentation steps — v0.8.0, identical in every seat's push because they are public. */
|
||||
steps?: DisplayStep[];
|
||||
/** The baseline for the step queue, sent on a connect only. */
|
||||
publicReset?: PublicFrame;
|
||||
/**
|
||||
* THE FOUR TRANSIENT SIGNALS, added 2026-08-23.
|
||||
*
|
||||
@@ -270,6 +316,8 @@ export function createRemoteSession(
|
||||
let menu: Menu | null = null;
|
||||
let lines: { text: string; tone: string }[] = [];
|
||||
const presence = new Map<PlayerIndex, { connected: boolean; seen: boolean }>();
|
||||
let displaySteps: DisplayStep[] = [];
|
||||
let displayReset: PublicFrame | null = null;
|
||||
let cues: string[] = [];
|
||||
let scheduled: number | null = null;
|
||||
let announcement: string | null = null;
|
||||
@@ -324,6 +372,21 @@ export function createRemoteSession(
|
||||
if (push.announcement !== undefined && push.announcement !== null) announcement = push.announcement;
|
||||
// Persists until another draw replaces it, matching the local session's own `justDrawn`.
|
||||
if (push.justDrawn !== undefined) justDrawnCard = push.justDrawn;
|
||||
/**
|
||||
* A RESET DISCARDS WHAT WAS QUEUED, rather than arriving alongside it.
|
||||
*
|
||||
* `publicReset` comes on a connect, which is also a RECONNECT — and a reconnecting client's
|
||||
* queue holds steps whose deltas chain off a baseline the server has since moved past. Merging
|
||||
* them onto the new baseline would draw a board that never existed. The history panel is what
|
||||
* carries what was missed; the animation does not replay it (§ v0.8.0).
|
||||
*/
|
||||
if (push.publicReset) {
|
||||
displayReset = push.publicReset;
|
||||
displaySteps = [];
|
||||
}
|
||||
// Accumulated, like cues: two pushes can land between two renders and every step is one thing
|
||||
// that happened.
|
||||
if (push.steps) displaySteps = [...displaySteps, ...push.steps];
|
||||
changed();
|
||||
};
|
||||
|
||||
@@ -376,6 +439,12 @@ export function createRemoteSession(
|
||||
},
|
||||
justDrawn: () => justDrawnCard,
|
||||
presence: () => [...presence].map(([seat, p]) => ({ seat, connected: p.connected, seen: p.seen })),
|
||||
takeDisplaySteps: () => displaySteps.splice(0, displaySteps.length),
|
||||
takeDisplayReset: () => {
|
||||
const reset = displayReset;
|
||||
displayReset = null;
|
||||
return reset;
|
||||
},
|
||||
close() {
|
||||
// `reportedGone` first: closing the stream fires `onerror`, and this is a deliberate exit, not
|
||||
// a game that vanished — `onGone` must not be called and land the page in "that game is no
|
||||
|
||||
@@ -40,6 +40,29 @@ if (heroImage && lightbox) {
|
||||
* other way costs a click and a lobby that says it cannot reach a server — which is legible, and
|
||||
* recoverable. So a slow or flaky probe leaves the door alone; only a definite answer closes it.
|
||||
*/
|
||||
/**
|
||||
* CARRY `?pace=` THROUGH THE DOORS — v0.8.0.3.
|
||||
*
|
||||
* Both doors are static hrefs that REPLACE the query string (`play.html?lobby`,
|
||||
* `play.html?solitaire`), so a `pace` typed on this page was silently dropped on the way in: Jesse
|
||||
* played a whole game believing he was at 7× when the play page had only ever seen `?lobby`. The
|
||||
* durable answer is the speed control on the play screen, which persists per viewer — this keeps the
|
||||
* URL lever honest for handing two playtesters different speeds, which is the only thing it was ever
|
||||
* for.
|
||||
*/
|
||||
try {
|
||||
const pace = new URLSearchParams(location.search).get('pace');
|
||||
if (pace !== null) {
|
||||
for (const door of Array.from(document.querySelectorAll('a.door'))) {
|
||||
const href = door.getAttribute('href');
|
||||
// Only the doors into the game, and only ones that have not been disabled above.
|
||||
if (href?.startsWith('./play.html?')) door.setAttribute('href', `${href}&pace=${encodeURIComponent(pace)}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// A door that keeps its own href is the status quo, not a broken page.
|
||||
}
|
||||
|
||||
const mpDoor = document.getElementById('door-multiplayer');
|
||||
if (mpDoor) {
|
||||
const close = (): void => {
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* 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, changedPiles } from '../sim/public-delta.ts';
|
||||
import type { PileKey } 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;
|
||||
/**
|
||||
* The piles the step now on screen moved, for the display to light.
|
||||
*
|
||||
* Here because this is the only place that holds both the frame before a step and the frame after
|
||||
* it — deriving it anywhere else would mean keeping a second copy of the board in step.
|
||||
*/
|
||||
lit(): readonly PileKey[];
|
||||
/** 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.
|
||||
*
|
||||
* `viewer` says which seat is watching, so THIS PLAYER'S OWN MOVES COST NO TIME. They are already on
|
||||
* screen: a seated player's own board is drawn from their authoritative `Frame`, not from the queue,
|
||||
* so holding their click for a dwell shows them nothing and delays the thing they actually want to
|
||||
* watch — the 700ms before a bot's turn starts animating is 700ms of their own move being replayed
|
||||
* at them. The step is still APPLIED, because the delta chain runs through it.
|
||||
*
|
||||
* Automatic phases have no player and are unaffected, which is what keeps TODO #18 working in
|
||||
* solitaire where every intent is the viewer's own.
|
||||
*/
|
||||
export function createStepQueue(
|
||||
pace: () => number = () => 1,
|
||||
viewer: () => number | null = () => null,
|
||||
): StepQueue {
|
||||
let shown: PublicFrame | null = null;
|
||||
let last: DisplayStep | null = null;
|
||||
let litPiles: readonly PileKey[] = [];
|
||||
let pending: DisplayStep[] = [];
|
||||
/** When the step now on screen is due to give way. Null when nothing is waiting. */
|
||||
let dueAt: number | null = null;
|
||||
|
||||
/** How long this step holds the screen — zero for the viewer's own moves; see above. */
|
||||
const dwell = (step: DisplayStep): number =>
|
||||
step.player !== null && step.player === viewer() ? 0 : dwellForStep(step, pace());
|
||||
|
||||
/** Applies one step to the displayed board. A step's delta chains off the previous step's frame. */
|
||||
const show = (step: DisplayStep): void => {
|
||||
const before = shown;
|
||||
shown = applyPublicDelta(shown, step.frame);
|
||||
last = step;
|
||||
/**
|
||||
* NOT FOR YOUR OWN MOVES. You drew that card; you do not need the deck flashed at you. Same rule
|
||||
* that gives your own steps no dwell — the display is for watching everybody else.
|
||||
*/
|
||||
litPiles = step.player !== null && step.player === viewer() ? [] : changedPiles(before, shown);
|
||||
};
|
||||
|
||||
return {
|
||||
reset(frame) {
|
||||
shown = frame;
|
||||
pending = [];
|
||||
dueAt = null;
|
||||
// Nothing was watched arriving at this board, so nothing on it is lit.
|
||||
litPiles = [];
|
||||
// `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) {
|
||||
// The LAST step of a burst still owes its dwell. Clearing `dueAt` here reported the queue
|
||||
// idle the instant that step was shown, which snapped the district panel home before anyone
|
||||
// could look at it — see `busy()`.
|
||||
if (dueAt !== null && now >= dueAt) 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 + dwell(first);
|
||||
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 + dwell(next);
|
||||
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) => dwell(s) > 0).length,
|
||||
showing: () => last,
|
||||
lit: () => litPiles,
|
||||
/**
|
||||
* STILL SHOWING SOMETHING, not just still holding something back.
|
||||
*
|
||||
* This was `pending.length > 0`, which went false the moment the last step of a burst was
|
||||
* shown — so the animation loop stopped and the district panel snapped back to the viewer's own
|
||||
* board without that step ever being visible. Reported from real play: "I briefly saw that it was
|
||||
* the bot's office area, then their turn was done and it pointed back to my office area."
|
||||
*
|
||||
* `dueAt` is non-null exactly while the step on screen has time left, so the two together mean
|
||||
* "there is more to come, or what is up has not had its moment yet".
|
||||
*/
|
||||
busy: () => pending.length > 0 || dueAt !== null,
|
||||
};
|
||||
}
|
||||
+63
-5
@@ -159,6 +159,60 @@ describe('Local Operations: drawing (§6.2)', () => {
|
||||
assert.notEqual(s.decks.departments[1]![0], target, 'refilled with the same card');
|
||||
});
|
||||
|
||||
it('a PLAYED timetabled train never comes back, but a discarded one does — Gitea#23', () => {
|
||||
/**
|
||||
* Jesse's ruling, 2026-09-10: *"Once you've played a regularly scheduled train and it's in the
|
||||
* salvage deck, that train is already on the timetable. It does not make sense to put that back
|
||||
* into a reshuffled home deck to get played again. By contrast, a regularly scheduled train
|
||||
* that's in a discard pile could potentially get reused later, and so should have that
|
||||
* capability. Extras run one time and then they're done — if they are in the Salvage deck, they
|
||||
* should get shuffled back in so that they could get run again."*
|
||||
*
|
||||
* So the test is WHERE the card is, not only what it is: the same card is spent in the Salvage
|
||||
* Yard and still runnable in a Department. That is what this pins, because it is the kind of rule
|
||||
* a later tidy-up would happily "simplify" into filtering by card kind everywhere.
|
||||
*/
|
||||
const s = game();
|
||||
const kindOfCard = (id: string): string => s.cards.get(id)?.kind.kind ?? '?';
|
||||
const pool = [...s.decks.homeOffice];
|
||||
const trains = pool.filter((id) => kindOfCard(id) === 'timetabledTrain');
|
||||
const extras = pool.filter((id) => kindOfCard(id) === 'extraTrain');
|
||||
const others = pool.filter((id) => !['timetabledTrain', 'extraTrain'].includes(kindOfCard(id)));
|
||||
assert.ok(trains.length >= 2 && extras.length >= 1 && others.length >= 5, 'the deal lacks the cards this needs');
|
||||
|
||||
const spentTrain = trains[0]!; // played: in the Salvage Yard, its slot taken
|
||||
const discardedTrain = trains[1]!; // never played: sitting in a Department
|
||||
const playedExtra = extras[0]!; // a single run, free to run again
|
||||
|
||||
s.decks.salvageYard = [spentTrain, playedExtra, ...others.slice(0, 3)];
|
||||
s.decks.departments = [[discardedTrain], [others[3]!], [others[4]!]];
|
||||
s.decks.homeOffice = [others[5]!];
|
||||
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
const r = applyIntent(s, 0, { type: 'draw.fromHomeOffice' });
|
||||
assert.ok(r.ok);
|
||||
assert.ok(r.events.some((e) => e.type === 'deckReshuffled'), 'no reshuffle was emitted');
|
||||
|
||||
const recovered = new Set([...s.decks.homeOffice, ...s.decks.departments.flat()]);
|
||||
const hands = new Set([...s.decks.hands.values()].flat());
|
||||
|
||||
// THE RULING, both halves.
|
||||
assert.ok(!recovered.has(spentTrain), 'a played timetabled train was shuffled back in');
|
||||
assert.ok(!hands.has(spentTrain), 'a played timetabled train was dealt back into a hand');
|
||||
assert.ok(
|
||||
s.decks.salvageYard.includes(spentTrain),
|
||||
'a played timetabled train should stay in the Salvage Yard, not vanish',
|
||||
);
|
||||
assert.ok(
|
||||
recovered.has(discardedTrain) || hands.has(discardedTrain),
|
||||
'a DISCARDED timetabled train must come back — it was never played, so its slot is open',
|
||||
);
|
||||
assert.ok(
|
||||
recovered.has(playedExtra) || hands.has(playedExtra),
|
||||
'a played Extra must come back — an Extra is one run, not a standing slot',
|
||||
);
|
||||
});
|
||||
|
||||
it('reshuffles the Salvage Yard and Departments back in when the deck runs out', () => {
|
||||
// §6.2 — "If drawing a card has depleted the Home Office deck, immediately collect all cards
|
||||
// from the Salvage Yard and three Department decks, reshuffle, and reestablish the Home Office
|
||||
@@ -182,7 +236,11 @@ describe('Local Operations: drawing (§6.2)', () => {
|
||||
assert.ok(r.ok);
|
||||
assert.ok(r.events.some((e) => e.type === 'deckReshuffled'), 'no reshuffle was emitted');
|
||||
|
||||
assert.equal(s.decks.salvageYard.length, 0, 'the Salvage Yard must be swept');
|
||||
// Swept EXCEPT the trains whose slots are already on the timetable — see the ruling test below.
|
||||
assert.ok(
|
||||
s.decks.salvageYard.every((id) => s.cards.get(id)?.kind.kind === 'timetabledTrain'),
|
||||
'the Salvage Yard must be swept apart from spent timetabled trains',
|
||||
);
|
||||
assert.ok(s.decks.homeOffice.length > 0, 'the deck must be re-established');
|
||||
assert.ok(
|
||||
s.decks.departments.every((p) => p.length === 1),
|
||||
@@ -1679,7 +1737,7 @@ describe('the Crew Tray is a train, and must be made up to leave (§8.2, Appendi
|
||||
const build = (toNose: boolean): string[] => {
|
||||
const s = game();
|
||||
const id = placeTray(s, at(0, 0), [car('boxcar')] as never);
|
||||
reduce(s, { type: 'carsCoupled', trayId: id, at: at(0, 0), stock: [car('hopper')] as never, from: [], toNose });
|
||||
reduce(s, { type: 'carsCoupled', player: 0, trayId: id, at: at(0, 0), stock: [car('hopper')] as never, from: [], toNose });
|
||||
return s.trays.get(id)!.consist.map((c) => c.type);
|
||||
};
|
||||
assert.deepEqual(build(true), ['hopper', 'boxcar'], 'running forward takes cars on the nose');
|
||||
@@ -1694,11 +1752,11 @@ describe('the Crew Tray is a train, and must be made up to leave (§8.2, Appendi
|
||||
const tray = s.trays.get(id)!;
|
||||
tray.engineAt = 0;
|
||||
|
||||
reduce(s, { type: 'carsCoupled', trayId: id, at: at(0, 0), stock: [car('hopper')] as never, from: [], toNose: true });
|
||||
reduce(s, { type: 'carsCoupled', player: 0, trayId: id, at: at(0, 0), stock: [car('hopper')] as never, from: [], toNose: true });
|
||||
assert.equal(tray.engineAt, 1, 'the engine should now have a car ahead of it');
|
||||
assert.deepEqual(tray.consist.map((c) => c.type), ['hopper', 'boxcar']);
|
||||
|
||||
reduce(s, { type: 'carsDropped', trayId: id, at: at(0, 0), stock: [car('hopper')] as never, fromNose: true });
|
||||
reduce(s, { type: 'carsDropped', player: 0, trayId: id, at: at(0, 0), stock: [car('hopper')] as never, fromNose: true });
|
||||
assert.equal(tray.engineAt, 0, 'setting out the nose cars puts the engine back in front');
|
||||
assert.deepEqual(tray.consist.map((c) => c.type), ['boxcar']);
|
||||
});
|
||||
@@ -1823,7 +1881,7 @@ describe('the engine is drawn pointing east or west, whatever track it is standi
|
||||
* So `facing` stays a PORT (movement needs one) and `railFacingOf` is what the board draws.
|
||||
*/
|
||||
const moved = (id: string, facing: 'n' | 's' | 'e' | 'w') =>
|
||||
({ type: 'trayMoved', trayId: id, from: at(0, 0), to: at(0, 0), movesRemaining: 3, facing }) as const;
|
||||
({ type: 'trayMoved', player: 0, trayId: id, from: at(0, 0), to: at(0, 0), movesRemaining: 3, facing }) as const;
|
||||
|
||||
it('carries the east-west sense across north-south track', () => {
|
||||
const s = game();
|
||||
|
||||
@@ -24,12 +24,13 @@ import { impediments, narrate } from '../src/sim/narrate.ts';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { actionMenu } from '../src/web/game.ts';
|
||||
import { newCollector } from '../src/sim/display-step.ts';
|
||||
import type { Game } from '../src/web/game.ts';
|
||||
|
||||
/** The thin wrapper `actionMenu` expects, built directly around an already-created multi-player state
|
||||
* — `newGame` (game.ts) hardcodes one player, so it cannot construct this for a multi-seat game. */
|
||||
const wrap = (s: GameState): Game =>
|
||||
({ state: s, seed: s.seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null });
|
||||
({ state: s, seed: s.seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null, display: newCollector() });
|
||||
|
||||
const competitive: GameConfig = {
|
||||
mode: 'competitive',
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* 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, MAX_PACE, PACE_LEVELS, 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');
|
||||
/**
|
||||
* `localOps.choose` IS AN ANNOUNCEMENT, not bookkeeping — moved 2026-09-09 after the first real
|
||||
* play on the test server. It is the line reading "Player Bot 1 chose to SWITCH", the heading for
|
||||
* everything that follows, and at zero dwell a bot's turn began with no sign of what it was about
|
||||
* to do.
|
||||
*/
|
||||
assert.equal(kindOf('localOps.choose'), 'action');
|
||||
|
||||
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('supports multipliers above 1, and keeps the tiers in proportion at every speed', () => {
|
||||
/**
|
||||
* Jesse, 2026-09-09, after the first play: keep switching and ordinary actions at DIFFERENT
|
||||
* delays, and support 2.0 and 3.0 as well as 1.5. So this pins both halves — that the larger
|
||||
* multipliers work at all, and that scaling never flattens the tiers into each other, since the
|
||||
* relative weighting is the design and the multiplier is only how fast it runs.
|
||||
*/
|
||||
for (const pace of [0.5, 1, 1.5, 2, 3]) {
|
||||
assert.equal(dwellFor('switch.move', pace), Math.round(DWELL.switching * pace));
|
||||
assert.equal(dwellFor('card.play', pace), Math.round(DWELL.action * pace));
|
||||
assert.ok(
|
||||
dwellFor('switch.move', pace) > dwellFor('card.play', pace),
|
||||
`at ${pace}x a switching move no longer outlasts an ordinary action`,
|
||||
);
|
||||
assert.equal(dwellFor('draw.end', pace), 0, 'bookkeeping stays free at every speed');
|
||||
}
|
||||
// A whole switching exercise at 3x is slow on purpose, and still not absurd.
|
||||
assert.equal(dwellFor('switch.move', 3) * 6, 18_000);
|
||||
|
||||
// And a typo cannot freeze the board: ?pace=300 from somebody meaning 3.00.
|
||||
assert.equal(dwellFor('switch.move', 300), DWELL.switching * MAX_PACE);
|
||||
assert.equal(dwellFor('switch.move', MAX_PACE + 5), dwellFor('switch.move', MAX_PACE));
|
||||
});
|
||||
|
||||
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('offers speeds a player actually reached for, and none the code would clamp', () => {
|
||||
/**
|
||||
* Jesse played a whole game believing he was at 7× and was in fact at 1×: `?pace=` shipped as the
|
||||
* only lever, and `index.html`'s doors are `play.html?lobby` / `play.html?solitaire`, so arriving
|
||||
* from the splash REPLACES the query string. Hence a real control on the play screen, and hence
|
||||
* this ladder — which must reach the speeds people ask for and must not offer one that
|
||||
* `dwellFor` would silently clamp.
|
||||
*/
|
||||
assert.equal(PACE_LEVELS[0], 0, 'off must be the first rung — #18 wants it turned off');
|
||||
assert.ok(PACE_LEVELS.includes(1), 'the default must be on the ladder');
|
||||
assert.ok(PACE_LEVELS.includes(7), '7x was asked for by name');
|
||||
/**
|
||||
* The ceiling is not theoretical. Jesse played at 10× — the top of the ladder as it then was —
|
||||
* and called it "still a bit fast, but followable", so the ladder has to go past the speed
|
||||
* somebody actually reached for and found insufficient.
|
||||
*/
|
||||
assert.ok(PACE_LEVELS.some((p) => p > 10), 'the ladder must go beyond the speed that was too fast');
|
||||
for (const p of PACE_LEVELS) {
|
||||
assert.ok(p <= MAX_PACE, `${p}x is past MAX_PACE, so the control would lie about it`);
|
||||
assert.equal(dwellFor('switch.move', p), Math.round(DWELL.switching * p));
|
||||
}
|
||||
// Strictly increasing, so stepping the control always changes the speed.
|
||||
for (let i = 1; i < PACE_LEVELS.length; i++) {
|
||||
assert.ok(PACE_LEVELS[i]! > PACE_LEVELS[i - 1]!, 'the ladder must be strictly increasing');
|
||||
}
|
||||
// The slowest rung has to be slow enough to be worth having: six switching moves at the top of
|
||||
// the ladder is a full minute, which is the "watch them struggle" case.
|
||||
const slowest = dwellFor('switch.move', PACE_LEVELS[PACE_LEVELS.length - 1]!) * 6;
|
||||
assert.ok(slowest >= 60_000, `the slowest a switching turn can be watched is ${slowest}ms`);
|
||||
});
|
||||
|
||||
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, player: null, 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', player: 1, lines: ['moved'], frame: { table: {} } }),
|
||||
DWELL.switching,
|
||||
);
|
||||
});
|
||||
|
||||
it('the speed control stretches the clock at a THIRD of the rate it stretches people', () => {
|
||||
/**
|
||||
* Two complaints, one from each direction, and the answer is between them.
|
||||
*
|
||||
* v0.8.0.3, from a 5× game: *"after my turn … I'm still subject to that same delay before it
|
||||
* moves on. That makes no sense."* — phases were scaling with everything else and walling off a
|
||||
* player's own turn. So they were pinned at their tabled beat.
|
||||
*
|
||||
* v0.8.0.7, from a 10× game: *"phases displayed on the upper line go by too quickly still.
|
||||
* Should be 4 times as long — at a guess."* — pinned was too short to read the caption.
|
||||
*
|
||||
* Damped scaling satisfies both: 1× unchanged, 10× lands exactly on the four-times guess, and
|
||||
* the cost stays bounded because phase beats cluster rather than accumulate.
|
||||
*/
|
||||
const phase = { cause: 'phase' as const, player: null, lines: ['New Train'], frame: { table: { phase: 'newTrain' } } };
|
||||
const theirs = { cause: 'switch.move' as const, player: 1, lines: ['moved'], frame: { table: {} } };
|
||||
|
||||
assert.equal(dwellForStep(phase, 1), DWELL.phase, '1x must be exactly the tabled beat');
|
||||
assert.equal(dwellForStep(phase, 10), DWELL.phase * 4, '10x must be four times it, as asked for');
|
||||
|
||||
for (const pace of [2, 3, 5, 7, 10, 15, 20]) {
|
||||
const p = dwellForStep(phase, pace);
|
||||
const t = dwellForStep(theirs, pace);
|
||||
assert.ok(p > DWELL.phase, `a phase must grow at ${pace}x`);
|
||||
assert.ok(
|
||||
p < DWELL.phase * pace,
|
||||
`a phase must grow SLOWER than the multiplier at ${pace}x, or the clock walls off the turn`,
|
||||
);
|
||||
assert.ok(t > p, `somebody's move must still outlast a phase beat at ${pace}x`);
|
||||
}
|
||||
// Off still means off, for the clock as much as for anybody's move; and below 1x the clock
|
||||
// follows the multiplier straight, because "faster" should mean everything.
|
||||
assert.equal(dwellForStep(phase, 0), 0);
|
||||
assert.equal(dwellForStep(theirs, 0), 0);
|
||||
assert.equal(dwellForStep(phase, 0.5), DWELL.phase * 0.5);
|
||||
});
|
||||
|
||||
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: the announcement, six moves, and an end that shows nothing.
|
||||
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, DWELL.action + 6 * DWELL.switching);
|
||||
assert.ok(total > 5_000 && total < 10_000, `a switching turn takes ${total}ms to watch`);
|
||||
assert.equal(watchableCount(turn), 7, 'the six moves and the announcement; not the end');
|
||||
});
|
||||
|
||||
it("a bot's ordinary turn is followable, which is what the first real play was not", () => {
|
||||
/**
|
||||
* MEASURED FROM A REAL GAME, then pinned. Jesse, after installing v0.8.0 on the test server:
|
||||
* *"bot play was way too fast. I briefly saw that it was the bot's office area then their turn
|
||||
* was done."* This is the shape that turn actually had — no switching in it at all, because
|
||||
* switching is not legal until there is track down — and under the original values it came to
|
||||
* 750ms for the whole thing.
|
||||
*/
|
||||
const turn: Intent['type'][] = [
|
||||
'localOps.choose',
|
||||
'draw.fromHomeOffice',
|
||||
'card.play',
|
||||
'draw.end',
|
||||
'localOps.choose',
|
||||
'freightAgent.stockOutbound',
|
||||
];
|
||||
const total = turn.reduce((ms, i) => ms + dwellFor(i), 0);
|
||||
assert.ok(total >= 3_000, `an ordinary bot turn is only ${total}ms — too fast to follow`);
|
||||
assert.equal(watchableCount(turn), 5, 'only the turn-ending bookkeeping is free');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* 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, changedPiles, 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 };
|
||||
|
||||
describe('which piles a step moved', () => {
|
||||
/**
|
||||
* MEASURED FROM REAL PLAY, then pinned. The table in `changedPiles` claims what each action moves,
|
||||
* and a claim in a comment is worth nothing unless something checks it — so this drives real games
|
||||
* and asserts the mapping holds, action by action.
|
||||
*/
|
||||
it('maps each action to the piles it actually touches', () => {
|
||||
const seen = new Map<string, Set<string>>();
|
||||
/**
|
||||
* TWO PASSES, because a single driver cannot reach every case. Left to itself the bot almost
|
||||
* never takes a Department card, and a driver that prefers one then never draws from the deck —
|
||||
* so each preference is played out separately and the assertions below require BOTH to have
|
||||
* been observed rather than passing on whichever happened to occur.
|
||||
*/
|
||||
for (const prefer of ['draw.fromDepartment', 'draw.fromHomeOffice'] as const) {
|
||||
for (const seed of [1917398, 191056, 4242]) {
|
||||
const s = newState(seed);
|
||||
for (let i = 0; i < 400; i++) {
|
||||
const actor = currentActorOfState(s);
|
||||
if (actor === null) break;
|
||||
const options = legalActions(s, actor);
|
||||
if (options.length === 0) break;
|
||||
const chosen =
|
||||
options.find((o) => o.type === prefer) ??
|
||||
options.find((o) => o.type === 'card.discard') ??
|
||||
options[i % options.length]!;
|
||||
const before = publicSnapshot(s);
|
||||
const r = applyIntent(s, actor, chosen);
|
||||
if (!r.ok) break;
|
||||
pump(s);
|
||||
const piles = changedPiles(before, publicSnapshot(s)).map((p) => p.replace(/dept\d/, 'dept'));
|
||||
if (!seen.has(chosen.type)) seen.set(chosen.type, new Set());
|
||||
for (const p of piles) seen.get(chosen.type)!.add(p);
|
||||
if (piles.length === 0) seen.get(chosen.type)!.add('(none)');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const of = (t: string): Set<string> => seen.get(t) ?? new Set();
|
||||
// NOT VACUOUS: the four cases the mapping is actually about must all have happened.
|
||||
for (const needed of ['draw.fromHomeOffice', 'draw.fromDepartment', 'card.discard', 'card.play']) {
|
||||
assert.ok(of(needed).size > 0, `${needed} never occurred, so its rule proved nothing`);
|
||||
}
|
||||
|
||||
// A HOME OFFICE DRAW MOVES THE COUNT AND NOTHING ELSE ON A PILE. The card is private; the deck
|
||||
// getting shorter is not, and it is the only thing a watcher can be shown.
|
||||
assert.deepEqual([...of('draw.fromHomeOffice')].sort(), ['home']);
|
||||
// A DEPARTMENT DRAW touches that Department, and sometimes the deck too — the pile refills from
|
||||
// it. Both are public, so both may light.
|
||||
for (const p of of('draw.fromDepartment')) {
|
||||
assert.ok(p === 'dept' || p === 'home', `a Department draw moved "${p}"`);
|
||||
}
|
||||
assert.ok(of('draw.fromDepartment').has('dept'), 'a Department draw must light its Department');
|
||||
// A DISCARD lands face up on a Department, and which one is public.
|
||||
assert.deepEqual([...of('card.discard')].sort(), ['dept']);
|
||||
// A PLAYED CARD that does not stay on the board lands face up in the Salvage Yard.
|
||||
assert.ok(of('card.play').has('salvage'), 'a played card must be able to light the Salvage Yard');
|
||||
// ENDING A PHASE moves no card anywhere, so nothing should light for it.
|
||||
for (const quiet of ['draw.end', 'loadUnload.end', 'switch.end', 'localOps.choose']) {
|
||||
if (of(quiet).size > 0) assert.deepEqual([...of(quiet)], ['(none)'], `${quiet} lit a pile`);
|
||||
}
|
||||
});
|
||||
|
||||
it('lights nothing without a previous frame to compare against', () => {
|
||||
// A reset has nothing to have watched arriving, so nothing on it is lit.
|
||||
const s = newState(4242);
|
||||
assert.deepEqual(changedPiles(null, publicSnapshot(s)), []);
|
||||
});
|
||||
});
|
||||
+104
-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', () => {
|
||||
const names = ['Ann', 'Bob', 'Cy'];
|
||||
|
||||
/** Everything one seat can see, as one string: their Frame, the public board, and their lines. */
|
||||
const everythingSeatSees = (g: ReturnType<typeof newMultiplayerGame>, seat: PlayerIndex): string =>
|
||||
JSON.stringify(snapshot(g.state, g.log, null, null, null, false, seat)) +
|
||||
'\n' + JSON.stringify(publicSnapshot(g.state)) +
|
||||
'\n' + g.log.map((l) => l.text).join('\n');
|
||||
/**
|
||||
* Everything one seat can see, split into the two halves the checks below treat differently.
|
||||
*
|
||||
* `structural` is the machine-readable state: their Frame, the public board, and the frame of every
|
||||
* presentation step they are sent (v0.8.0, TODO #13). `narration` is what the table was TOLD.
|
||||
*
|
||||
* Steps are folded in here rather than given a test of their own so every case below covers them:
|
||||
* the blind draw, the pending decision, Employee Rotation before and after the seating moves, and
|
||||
* the played-out game. Their `lines` are a slice of `g.log` by construction, so the log covers the
|
||||
* narration half of a step and does not need to be searched twice.
|
||||
*/
|
||||
const everythingSeatSees = (g: ReturnType<typeof newMultiplayerGame>, seat: PlayerIndex): {
|
||||
structural: string;
|
||||
history: string;
|
||||
narration: string[];
|
||||
} => ({
|
||||
/**
|
||||
* `[]` for the Frame's own lines, MATCHING PRODUCTION. `frameFor()` (`server/session.ts`) has
|
||||
* passed no log since #97 — narration goes out incrementally through `Push.lines` instead — so
|
||||
* embedding it here audits a path that no longer exists, and worse, it puts the whole log inside
|
||||
* `structural` where the face-up-pile rule below cannot reach it. The log is audited in full as
|
||||
* `narration`; this is a de-duplication, not a relaxation.
|
||||
*/
|
||||
structural:
|
||||
JSON.stringify(snapshot(g.state, [], null, null, null, false, seat)) +
|
||||
'\n' + JSON.stringify(publicSnapshot(g.state)),
|
||||
/**
|
||||
* THE STEP FRAMES ARE A RECORD OF WHAT WAS PUBLIC OVER TIME, not a view of the position now —
|
||||
* so they get the PRECISE check and not the fuzzy one, for the same reason the face-up-pile
|
||||
* lines do.
|
||||
*
|
||||
* Every one is built by `deltaPublicFrame` over `publicSnapshot`, which the allow-list test at
|
||||
* the bottom of this file pins property by property; that is what guarantees a step frame is
|
||||
* clean. Searching their accumulation for a card NAME asks "was this ever public?" and answers
|
||||
* a question nobody was posing: Train 6 sat face-up in a Department at step 40 and is in Ann's
|
||||
* hand at step 120, and both facts are correct. A card ID is different — narration never renders
|
||||
* one and no public field carries an opponent's, so finding one anywhere is still proof.
|
||||
*/
|
||||
history: JSON.stringify(g.display.steps.map((step) => step.frame)),
|
||||
narration: g.log.map((l) => l.text),
|
||||
});
|
||||
|
||||
/**
|
||||
* A FACE-UP PILE IS ALLOWED TO NAME THE CARD ON IT, and the log is history rather than a view.
|
||||
*
|
||||
* §2.6: the three Department piles and the Salvage Yard are face up, "so players can audit
|
||||
* discards" — a discard goes onto one precisely so a rival can take it. So "Player Ann discarded
|
||||
* Train 6 face-up on top of Department 3" is the record working, and it stays in the log after Ann
|
||||
* takes the card back into her hand. The name-based check below would otherwise read that historical
|
||||
* line as proof of what Ann is holding NOW, which is how it reported a leak against correct code on
|
||||
* seed 1917398.
|
||||
*
|
||||
* These lines are excluded from the NAME check only. The card-id check and the seed check still run
|
||||
* over them, because those are precise: an id is unique, so finding one is proof, and narration
|
||||
* never renders a raw id.
|
||||
*
|
||||
* **This does not weaken the blind-draw detection**, which is the leak this whole net was built
|
||||
* for (v0.7.9.2, "Red Flags"): a blind draw names the HOME OFFICE DECK, which is face down and
|
||||
* matches nothing here.
|
||||
*/
|
||||
const namesAFaceUpPile = (line: string): boolean => /Department|Salvage/i.test(line);
|
||||
|
||||
/**
|
||||
* Every secret belonging to somebody OTHER than `seat`: their card ids, and the names those ids
|
||||
@@ -265,8 +321,14 @@ describe('#91 — nothing private survives serialisation, in any state', () => {
|
||||
* This is what caught the blind-draw leak in v0.7.9.2: "Red Flags" was in exactly one hand, and it
|
||||
* was in the log.
|
||||
*/
|
||||
const secretsOfOthers = (g: ReturnType<typeof newMultiplayerGame>, seat: PlayerIndex): { what: string; value: string }[] => {
|
||||
const out: { what: string; value: string }[] = [];
|
||||
const secretsOfOthers = (
|
||||
g: ReturnType<typeof newMultiplayerGame>,
|
||||
seat: PlayerIndex,
|
||||
): { what: string; value: string; precise: boolean }[] => {
|
||||
// `precise` marks evidence that is proof on its own — a card id is unique, so finding one
|
||||
// anywhere is a leak. A NAME is circumstantial and is searched over a narrower string; see
|
||||
// `namesAFaceUpPile`.
|
||||
const out: { what: string; value: string; precise: boolean }[] = [];
|
||||
// How many cards in the whole game carry each name, and how many of those are in a given hand.
|
||||
const totalByName = new Map<string, number>();
|
||||
for (const id of g.state.cards.keys()) {
|
||||
@@ -282,10 +344,10 @@ describe('#91 — nothing private survives serialisation, in any state', () => {
|
||||
heldByName.set(n, (heldByName.get(n) ?? 0) + 1);
|
||||
}
|
||||
for (const id of hand) {
|
||||
out.push({ what: `${p.name}'s card id`, value: id });
|
||||
out.push({ what: `${p.name}'s card id`, value: id, precise: true });
|
||||
const name = cardName(g.state, id);
|
||||
if (totalByName.get(name) === heldByName.get(name)) {
|
||||
out.push({ what: `${p.name}'s card name, unique to their hand`, value: name });
|
||||
out.push({ what: `${p.name}'s card name, unique to their hand`, value: name, precise: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -295,15 +357,19 @@ describe('#91 — nothing private survives serialisation, in any state', () => {
|
||||
/** Runs the whole net over one state, and says which state failed if it does. */
|
||||
const audit = (g: ReturnType<typeof newMultiplayerGame>, where: string): void => {
|
||||
for (const seat of g.state.players.map((p) => p.index)) {
|
||||
const seen = everythingSeatSees(g, seat);
|
||||
for (const { what, value } of secretsOfOthers(g, seat)) {
|
||||
const { structural, history, narration } = everythingSeatSees(g, seat);
|
||||
const everything = structural + '\n' + history + '\n' + narration.join('\n');
|
||||
// Names are fuzzy evidence, so they are searched everywhere EXCEPT the lines a face-up pile
|
||||
// is entitled to name a card on. Ids are precise and are searched everywhere.
|
||||
const forNames = structural + '\n' + narration.filter((l) => !namesAFaceUpPile(l)).join('\n');
|
||||
for (const { what, value, precise } of secretsOfOthers(g, seat)) {
|
||||
assert.ok(
|
||||
!seen.includes(value),
|
||||
!(precise ? everything : forNames).includes(value),
|
||||
`${where}: seat ${seat} can see ${what} ("${value}")`,
|
||||
);
|
||||
}
|
||||
// The seed is the whole future of the deal and must not reach a seat by any route.
|
||||
assert.ok(!seen.includes(String(g.seed)), `${where}: seat ${seat} can see the seed ${g.seed}`);
|
||||
assert.ok(!everything.includes(String(g.seed)), `${where}: seat ${seat} can see the seed ${g.seed}`);
|
||||
}
|
||||
// And the spectator board, which has no seat and is therefore entitled to nothing private.
|
||||
const pub = JSON.stringify(publicSnapshot(g.state));
|
||||
@@ -346,6 +412,28 @@ describe('#91 — nothing private survives serialisation, in any state', () => {
|
||||
audit(g, 'after a blind draw');
|
||||
});
|
||||
|
||||
it('the net actually sees the presentation steps it claims to cover (v0.8.0)', () => {
|
||||
/**
|
||||
* Guards the COVERAGE, not the code. `everythingSeatSees` folds `display.steps` into the string
|
||||
* every case above is audited against — which is worth nothing if that array is empty in
|
||||
* practice. So: play a real game, and assert both that steps accumulated and that the audited
|
||||
* string contains them.
|
||||
*/
|
||||
const g = newMultiplayerGame(1917398, config, names);
|
||||
play(g, 120);
|
||||
assert.ok(g.display.steps.length > 20, `only ${g.display.steps.length} steps — the net covers little`);
|
||||
const { history, narration } = everythingSeatSees(g, 0 as PlayerIndex);
|
||||
assert.ok(
|
||||
history.includes(JSON.stringify(g.display.steps.map((step) => step.frame))),
|
||||
'the audited string does not actually contain the step frames',
|
||||
);
|
||||
// And a step's own narration is a slice of the log, so the log half covers it.
|
||||
const fromSteps = g.display.steps.flatMap((step) => step.lines.map((l) => l.text));
|
||||
assert.ok(fromSteps.length > 0, 'the steps carried no narration to cover');
|
||||
assert.ok(fromSteps.every((t) => narration.includes(t)), 'a step said something the log did not');
|
||||
audit(g, 'a played game with presentation steps');
|
||||
});
|
||||
|
||||
it('mid-game, with real hands and a built board', () => {
|
||||
// A DISTINCTIVE seed, deliberately. Seed 7 makes the seed check meaningless — "7" is in "Train
|
||||
// 7", in every coordinate and in half the numbers on the board — so it reported a leak that was
|
||||
@@ -436,6 +524,9 @@ describe('#91 — nothing private survives serialisation, in any state', () => {
|
||||
// The rules the game was dealt under, and the score.
|
||||
'houseRules', 'mode', 'optionalRules', 'days', 'minCombinedRevenue',
|
||||
'maxCollisionsPerDay', 'maxCollisionsTotal', 'collisionsToday', 'collisionsTotal',
|
||||
// What the Day that just ended finished on. Public for the same reason the running counts are:
|
||||
// a collision happens on the Mainline in front of everybody.
|
||||
'collisionsPrevDay',
|
||||
'status', 'outcome', 'extraDays', 'extensionVotes', 'official', 'tally',
|
||||
// Names, seats, revenue and HAND SIZE — never hand contents.
|
||||
'players',
|
||||
|
||||
+56
-9
@@ -6,6 +6,9 @@
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { pump } from '../src/engine/advance.ts';
|
||||
@@ -42,9 +45,9 @@ const SAMPLES: GameEvent[] = [
|
||||
{ type: 'phaseBegan', phase: 'mainline' },
|
||||
{ type: 'actorChanged', player: 0 },
|
||||
{ type: 'localOpsOptionChosen', player: 0, option: 'switch' },
|
||||
{ type: 'trayMoved', trayId: 't0', from: { row: 0, col: 0 }, to: { row: 0, col: 1 }, movesRemaining: 5 },
|
||||
{ type: 'carsCoupled', trayId: 't0', at: { row: 0, col: 1 }, stock: [{ type: 'hopper', loaded: false }], from: [{ row: 0, col: 1 }], toNose: true },
|
||||
{ type: 'carsDropped', trayId: 't0', at: { row: 1, col: 0 }, stock: [{ type: 'hopper', loaded: false }] },
|
||||
{ type: 'trayMoved', player: 0, trayId: 't0', from: { row: 0, col: 0 }, to: { row: 0, col: 1 }, movesRemaining: 5 },
|
||||
{ type: 'carsCoupled', player: 0, trayId: 't0', at: { row: 0, col: 1 }, stock: [{ type: 'hopper', loaded: false }], from: [{ row: 0, col: 1 }], toNose: true },
|
||||
{ type: 'carsDropped', player: 0, trayId: 't0', at: { row: 1, col: 0 }, stock: [{ type: 'hopper', loaded: false }] },
|
||||
{ type: 'cardDrawn', player: 0, source: 'homeOffice', cardId: 'c1' },
|
||||
{ type: 'cardPlayed', player: 0, cardId: 'c1', placement: { row: 1, col: 0 }, variant: 0 },
|
||||
{ type: 'officeUpgraded', player: 0, from: 'whistlePost', to: 'depot' },
|
||||
@@ -72,12 +75,56 @@ const SAMPLES: GameEvent[] = [
|
||||
|
||||
describe('narration', () => {
|
||||
it('covers every event type the engine can emit', () => {
|
||||
// Guards against a new event type slipping in unnarrated.
|
||||
const covered = new Set(SAMPLES.map((e) => e.type));
|
||||
const declared = new Set<string>();
|
||||
for (const e of SAMPLES) declared.add(e.type);
|
||||
assert.equal(covered.size, 30, 'sample list is out of step with GameEvent');
|
||||
assert.equal(declared.size, 30);
|
||||
/**
|
||||
* THIS TEST USED TO BUILD BOTH SETS FROM `SAMPLES` and compare them to each other, so it could
|
||||
* only ever assert that the sample list had 30 distinct entries — the one thing it could not
|
||||
* detect was the thing its comment promised, a new `GameEvent` slipping in unnarrated. Fixed
|
||||
* 2026-09-09 while adding the v0.8.0 step collector, which made the event union load-bearing for
|
||||
* a second reader.
|
||||
*
|
||||
* The union is read out of `src/engine/events.ts` rather than hand-listed, the same way
|
||||
* `test/pacing.test.ts` reads the intent union: a list maintained by hand is a list that goes
|
||||
* stale, which is how this got here.
|
||||
*/
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const declared = new Set(
|
||||
[...readFileSync(join(here, '../src/engine/events.ts'), 'utf8').matchAll(/type: '([a-zA-Z]+)'/g)]
|
||||
.map((m) => m[1]!),
|
||||
);
|
||||
const narrated = new Set(
|
||||
[...readFileSync(join(here, '../src/sim/narrate.ts'), 'utf8').matchAll(/case '([a-zA-Z]+)':/g)]
|
||||
.map((m) => m[1]!),
|
||||
);
|
||||
assert.ok(declared.size > 40, `only ${declared.size} event types parsed — the parse is wrong`);
|
||||
|
||||
// THE INVARIANT THAT MATTERS: an event the engine can emit and `narrate` has no case for falls
|
||||
// through to a placeholder, in front of a player. This is the check the old version promised.
|
||||
const unnarrated = [...declared].filter((t) => !narrated.has(t));
|
||||
assert.deepEqual(unnarrated, [], 'these event types can be emitted and have no narration case');
|
||||
|
||||
const covered = new Set<string>(SAMPLES.map((e) => e.type));
|
||||
const unknown = [...covered].filter((t) => !declared.has(t));
|
||||
assert.deepEqual(unknown, [], 'these samples name an event the engine no longer declares');
|
||||
|
||||
/**
|
||||
* THE KNOWN GAP, PINNED SO IT CANNOT GROW.
|
||||
*
|
||||
* `SAMPLES` exercises the TEXT of 30 of the 55 declared events; the other 25 have a narration
|
||||
* case (checked above) but no sample, so nothing proves their sentence is any good. Found
|
||||
* 2026-09-09 — the old test built both of its sets from `SAMPLES` and compared them to each
|
||||
* other, so it could only ever assert that the sample list had 30 distinct entries, and the one
|
||||
* thing it could not detect was the thing its comment promised.
|
||||
*
|
||||
* Pinned rather than fixed: writing 25 fixtures is a job of its own, and a bad sentence is worth
|
||||
* finding deliberately rather than in a rush. What this does guarantee is that a NEW event type
|
||||
* cannot join the unsampled set silently.
|
||||
*/
|
||||
const unsampled = [...declared].filter((t) => !covered.has(t)).sort();
|
||||
assert.equal(
|
||||
unsampled.length,
|
||||
25,
|
||||
`the unsampled set changed (${unsampled.length}): add a sample for a new event, or update this count`,
|
||||
);
|
||||
});
|
||||
|
||||
it('gives every event a specific, non-empty sentence', () => {
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* 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.
|
||||
// `.end` only: `localOps.choose` became an announcement worth watching after the first real play.
|
||||
const bookkeeping = steps.filter((s) => s.cause.endsWith('.end'));
|
||||
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('holds the LAST step of a burst for its dwell — the v0.8.0 snap-back bug', () => {
|
||||
/**
|
||||
* REGRESSION. `busy()` was `pending.length > 0`, so the instant the final step of a burst was
|
||||
* shown the queue reported idle: the animation loop stopped and the district panel snapped back
|
||||
* to the viewer's own board without that step ever being looked at. Jesse, from the first real
|
||||
* play on the test server: *"I briefly saw that it was the bot's office area then their turn was
|
||||
* done and it pointed back to my office area"*, and the countdown row appeared "very briefly".
|
||||
*
|
||||
* The panel follows `busy()`, so this is the property that keeps somebody else's board on screen
|
||||
* for as long as their move is being shown.
|
||||
*/
|
||||
const { steps } = realSteps(1917398, 400);
|
||||
const one = steps.filter((s) => s.cause === 'switch.move').slice(0, 1);
|
||||
assert.equal(one.length, 1);
|
||||
|
||||
const q = createStepQueue();
|
||||
q.reset(baseline(1917398));
|
||||
q.push(one);
|
||||
|
||||
q.advance(0);
|
||||
assert.equal(q.behind(), 0, 'nothing is queued behind it');
|
||||
assert.equal(q.busy(), true, 'but it is still being shown, so the queue is not idle');
|
||||
|
||||
q.advance(DWELL.switching - 1);
|
||||
assert.equal(q.busy(), true, 'still inside its dwell');
|
||||
|
||||
q.advance(DWELL.switching);
|
||||
assert.equal(q.busy(), false, 'and idle only once its moment has passed');
|
||||
});
|
||||
|
||||
it("does not spend time replaying the viewer's own moves", () => {
|
||||
// A seated player's own board is drawn from their authoritative Frame, so they have already seen
|
||||
// their own click. Holding it delays the thing they wanted to watch — a bot's turn.
|
||||
const { steps } = realSteps(1917398, 400);
|
||||
const mine = steps.filter((s) => s.player === 0 && s.cause === 'switch.move').slice(0, 3);
|
||||
assert.equal(mine.length, 3, 'need three of seat 0\'s own moves');
|
||||
|
||||
const asSeat0 = createStepQueue(() => 1, () => 0);
|
||||
asSeat0.reset(baseline(1917398));
|
||||
asSeat0.push(mine);
|
||||
// Twice at the same instant: the first call shows the head of the burst, the second collapses the
|
||||
// zero-dwell run behind it. In the page that is two animation frames, ~16ms apart.
|
||||
asSeat0.advance(0);
|
||||
asSeat0.advance(0);
|
||||
assert.equal(asSeat0.busy(), false, "the viewer's own moves must cost no time at all");
|
||||
assert.equal(asSeat0.behind(), 0, 'and must never be counted as something to wait for');
|
||||
|
||||
// The same steps seen by somebody else are worth watching.
|
||||
const asSpectator = createStepQueue(() => 1, () => 1);
|
||||
asSpectator.reset(baseline(1917398));
|
||||
asSpectator.push(mine);
|
||||
asSpectator.advance(0);
|
||||
assert.equal(asSpectator.busy(), true, "another seat's moves are worth showing");
|
||||
assert.equal(asSpectator.behind(), 2);
|
||||
});
|
||||
|
||||
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('can always be emptied, so a player is never stranded behind it', () => {
|
||||
/**
|
||||
* "Your Move" is put away while the board is catching up (v0.8.0.6), which makes `busy()` the
|
||||
* thing standing between a player and their own turn. So the ways it can be cleared matter more
|
||||
* than they did: `skip()` must always work, from any state, including one where the clock has
|
||||
* never advanced — which is exactly the situation a page with no `requestAnimationFrame` is in,
|
||||
* and how this was found.
|
||||
*/
|
||||
const { steps, final } = realSteps(1917398, 400);
|
||||
const q = createStepQueue();
|
||||
q.reset(baseline(1917398));
|
||||
q.push(steps);
|
||||
// Never advanced at all: no frame has been shown, and the queue is full.
|
||||
assert.equal(q.busy(), true);
|
||||
assert.equal(q.skip(), true, 'a never-advanced queue must still be skippable');
|
||||
assert.equal(q.busy(), false, 'and must be idle afterwards, or the player stays locked out');
|
||||
assert.deepEqual(q.current(), final);
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
+103
-1
@@ -23,7 +23,7 @@ import { variantsFor } from '../src/engine/track.ts';
|
||||
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
|
||||
import type { DivisionView } from '../src/sim/view.ts';
|
||||
import { ENHANCEMENT_RULES, STAGES_PER_DAY } from '../src/engine/content.ts';
|
||||
import { dayEndHtml, facilitiesHtml, resultsHtml, timetableHtml } from '../src/web/panels.ts';
|
||||
import { dayEndHtml, facilitiesHtml, pilesHtml, resultsHtml, timetableHtml } from '../src/web/panels.ts';
|
||||
import { turnChartHtml } from '../src/sim/turnchart.ts';
|
||||
import { fieldSelectors } from '../src/web/settings-form.ts';
|
||||
import { record, renderHtml } from '../src/sim/replay.ts';
|
||||
@@ -3614,6 +3614,108 @@ describe('the Day rolling over says so (Gitea#10)', () => {
|
||||
assert.ok(html.includes('3 Days left'), `the Days remaining are wrong:\n${html}`);
|
||||
});
|
||||
|
||||
it('draws the Home Office deck, face down, with its count', () => {
|
||||
/**
|
||||
* `f.deck` has carried the face-down count since the Frame existed and NOTHING drew it — the
|
||||
* exact display gap `test/display-gaps.test.ts` sweeps for, surviving in the panel that draws
|
||||
* every other pile. Asked for by Jesse 2026-09-10, who also wanted somewhere for a draw to
|
||||
* flash: taking a card off this deck is the commonest move nobody can see.
|
||||
*/
|
||||
const s = createEngineGame({
|
||||
id: 'piles',
|
||||
seed: 5,
|
||||
config: {
|
||||
mode: 'competitive', days: 5, minCombinedRevenue: 60,
|
||||
maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
},
|
||||
playerNames: ['Joe', 'Bot 1'],
|
||||
});
|
||||
const f = snapshot(s, [], null);
|
||||
assert.ok(f.deck > 0, 'the deal should leave cards in the Home Office deck');
|
||||
|
||||
const html = pilesHtml(f);
|
||||
assert.ok(html.includes('Home Office'), `no Home Office pile:\n${html}`);
|
||||
assert.ok(html.includes(`>${f.deck}<`), 'the face-down count is not shown');
|
||||
// Face down means the card slot must NOT name a card — that is the whole point of the pile.
|
||||
assert.ok(html.includes('facedown'), 'the Home Office pile is not marked face down');
|
||||
assert.ok(html.includes('face down'), 'the card slot should say so rather than naming a card');
|
||||
// It comes first: a card travels out of here, then onto a Department or the Salvage Yard.
|
||||
assert.ok(
|
||||
html.indexOf('Home Office') < html.indexOf('Dept 1'),
|
||||
'the draw deck should be read before the piles cards land on',
|
||||
);
|
||||
});
|
||||
|
||||
it('lights only the pile a watched move touched', () => {
|
||||
const s = createEngineGame({
|
||||
id: 'piles2',
|
||||
seed: 5,
|
||||
config: {
|
||||
mode: 'competitive', days: 5, minCombinedRevenue: 60,
|
||||
maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
},
|
||||
playerNames: ['Joe', 'Bot 1'],
|
||||
});
|
||||
const f = snapshot(s, [], null);
|
||||
|
||||
assert.equal(pilesHtml(f).includes('pilelit'), false, 'nothing is lit when nothing was watched');
|
||||
|
||||
const home = pilesHtml(f, ['home']);
|
||||
assert.equal((home.match(/pilelit/g) ?? []).length, 1, 'exactly one pile should light');
|
||||
assert.ok(
|
||||
home.indexOf('pilelit') < home.indexOf('Dept 1'),
|
||||
'a Home Office draw must light the Home Office pile, not a Department',
|
||||
);
|
||||
|
||||
const dept2 = pilesHtml(f, ['dept1']);
|
||||
assert.equal((dept2.match(/pilelit/g) ?? []).length, 1);
|
||||
assert.ok(dept2.indexOf('Dept 2') > dept2.indexOf('Dept 1'), 'order sanity');
|
||||
// Two piles can move at once — a Department draw that refills from the deck.
|
||||
assert.equal((pilesHtml(f, ['home', 'dept0']).match(/pilelit/g) ?? []).length, 2);
|
||||
});
|
||||
|
||||
it('reports the ENDED Day\'s collisions, not the fresh Day\'s zero', () => {
|
||||
/**
|
||||
* Jesse, 2026-09-09, at the end of a Day 1 with two collisions in it: *"It shows a total of two
|
||||
* collisions, but zero today. Since we just finished day one, that does seem to be a
|
||||
* contradiction."*
|
||||
*
|
||||
* The cause is a one-line ordering fact: `advance.ts` increments the Day and then zeroes
|
||||
* `collisionsToday`, and this dialog is drawn from the frame whose Day went UP — so it read the
|
||||
* fresh Day's zero and printed it beside a running total that could not agree with it. The count
|
||||
* is captured at the rollover now, and the dialog names the Day rather than saying "today".
|
||||
*/
|
||||
const s = createEngineGame({
|
||||
id: 'collide',
|
||||
seed: 5,
|
||||
config: {
|
||||
mode: 'competitive',
|
||||
days: 5,
|
||||
minCombinedRevenue: 60,
|
||||
maxCollisionsPerDay: 3,
|
||||
maxCollisionsTotal: 10,
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
},
|
||||
playerNames: ['Joe', 'Bot 1'],
|
||||
});
|
||||
// The state as the rollover out of Day 1 leaves it: two collisions happened, `today` is reset.
|
||||
s.clock.day = 2;
|
||||
s.collisionsPrevDay = 2;
|
||||
s.collisionsToday = 0;
|
||||
s.collisionsTotal = 2;
|
||||
|
||||
const html = dayEndHtml(snapshot(s, [], null));
|
||||
assert.ok(html.includes('Day 1 has ended'), `wrong Day named:\n${html}`);
|
||||
assert.ok(html.includes('<b>2</b> on Day 1'), `the ended Day's collisions are wrong:\n${html}`);
|
||||
assert.ok(html.includes('<b>2</b> in all'), `the running total is wrong:\n${html}`);
|
||||
assert.doesNotMatch(html, /<b>0<\/b> today/, `still reporting the fresh Day's zero:\n${html}`);
|
||||
// The contradiction itself: a Day-end dialog must never claim fewer in all than on that Day.
|
||||
assert.doesNotMatch(html, /<b>0<\/b> on Day 1/, 'reported no collisions on a Day that had two');
|
||||
});
|
||||
|
||||
it('counts the last Day as the last Day rather than promising more', () => {
|
||||
const html = dayEndHtml(frameAt(6));
|
||||
assert.ok(html.includes('Day 5 has ended'), 'the final Day is misnamed');
|
||||
|
||||
Reference in New Issue
Block a user