Compare commits

...
8 Commits
Author SHA1 Message Date
Jesse.MarkowitzandClaude Opus 5 072029b1f7 v0.8.0.8 — a played train does not come back; a discarded one does
Jesse's ruling on the question v0.8.0.7 filed: once a regularly scheduled train has
been played its number is on the timetable, so putting it back into a reshuffled
deck to be played again makes no sense. The same card sitting in a discard pile was
never played and its slot is still open, so it should come back. An Extra is a
single run rather than a standing slot, so a played one is free to run again.

The test is therefore WHERE the card is, not only what it is — which is worth
writing down, because it is exactly the rule a later tidy-up would simplify into
filtering by kind everywhere.

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 — four scheduled trains left eight entries in a pile holding four cards.
Nothing read it, it inflated the depth, it displayed as "a card", and it would have
been swept into the draw deck to be drawn as an id with nothing behind it. Removed,
which retires the phantom-id class rather than papering over it, so v0.8.0.7's
cardName resolver for it goes too.

Games in progress resume: no predicate changed its answer, and a draw is a draw
whatever is on top. What differs is the Yard's depth, which was double-counting,
and what a reshuffle recovers — and reshuffles are effectively unreachable, with
zero seen across eight games driven to 4000 moves.

Closes #23.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
2026-09-10 07:19:47 -04:00
Jesse.MarkowitzandClaude Opus 5 d0e5091824 v0.8.0.7 — the Salvage Yard had nothing to say, and phases too little time to read
The Salvage Yard was face up all along; its tile just read "a card". apply.ts
pushes a synthetic train-<n> id on trainScheduled, nothing in s.cards matches it,
and cardName fell through to its default — and since a train is scheduled several
times a Day that id is on top most of the time. Measured before touching anything:
the tile read "a card" from the opening frame through 60 pushes while its depth
climbed from 2 to 8. cardName resolves it now, in sim/view.ts, because this is a
name.

The engine half is filed as Gitea#23 rather than fixed here. reshuffleIfDepleted
sweeps the Salvage Yard back into the draw deck, so that synthetic id can be
shuffled in and drawn into a hand as an id with no card behind it. Eight games
driven to 4000 moves across eight seeds produced zero reshuffles, so it is latent;
there are two defensible fixes and the choice turns on what the synthetic id is
for, which is not a call to make while fixing a label.

And phases scale with the speed control again, damped to a third of the rate. They
were pinned in v0.8.0.3 because scaling them walled off a player's own turn; pinned
turns out to be too short to read at 10x. Damped satisfies both: 1x unchanged, 10x
lands exactly on the four-times guess. Bounded because phase beats cluster rather
than accumulate — 1.0 per push on average, 4 at worst, so the wait after a move is
~2.4s typical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
2026-09-10 06:58:09 -04:00
Jesse.MarkowitzandClaude Opus 5 64e8ce584f v0.8.0.6 — your move waits its turn, the lit pile keeps asking to be looked at
Your actions are put away while the board is catching up. The board on screen is
behind the game, so a move offered there is a move against a position that has
already moved on — and the screen had grown to four things competing at once: the
district, the history, the catching-up row, and a lit pile. Skip is one click away,
so the wait stays voluntary.

That 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. Caught by the DOM-stub test that has been proving this page still starts
since long before any of this existed. No rAF now means draw everything at once,
which is what pace 0 does deliberately, and a queue that throws empties itself
rather than stranding anyone.

The lit pile was never brief: measured, it stays lit for 6997ms at 10x. It was a
single flash over a dark fill, easy to miss while watching the district — a state
that settles stops asking to be looked at. It pulses now for as long as the move is
up.

And the pace ceiling was not theoretical. 10x was the top of the ladder and was
reported still a bit fast; it runs to 20 now. A control whose limit is reached in
ordinary use has the wrong limit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
2026-09-10 05:49:01 -04:00
Jesse.MarkowitzandClaude Opus 5 3fca325699 v0.8.0.5 — the Home Office deck, and lighting the pile a move touched
"Many operations still occurred too fast for me to see", at 10x — where an action
already holds the screen for seven seconds. So it was never duration: a bot drawing
a card changes one number in a panel nobody is watching, and the board sits
unchanged. Raising the dwell was the wrong lever and it had been pulled three
times.

f.deck has carried the face-down count since the Frame existed and nothing drew it
— the display gap test/display-gaps.test.ts sweeps for, surviving in the one panel
that draws every other pile. It is a tile now, first in the row, face down, because
that is the order a card travels and not knowing what is on top is the point.

And the piles a move touched are lit for as long as that move is on screen. Derived
from the frames either side of a step rather than sent, so nothing joins the
protocol and the 0.8.1 board gets it free. What lights follows what is public, and
was measured across four seeds rather than reasoned about: a Home Office draw
lights the deck and never names the card; a Department draw lights that pile, and
the deck too when it refills; a discard lights the Department it lands on; a played
card lights the Salvage Yard. Switching and new trains light nothing here — they
move the board, which the district panel already follows.

A state rather than a flash: the timetable's fixed 1.5s animation would be over
long before a seven-second pause. Not for your own moves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
2026-09-10 04:35:59 -04:00
Jesse.MarkowitzandClaude Opus 5 fc40fc39ed v0.8.0.4 — take the test server's name back out of the tracked files
Both repositories allow anonymous clone — checked rather than assumed: info/refs
for git-upload-pack answers 200 for each, git-receive-pack answers 401. So
everything committed here is public, and tracked files are supposed to carry
placeholders rather than real hosts.

Ten mentions added while building v0.8.0 are now "the test server" or "the target
hardware", across CHANGELOG.md, the common-board plan's three deferral banners,
sim/pacing.ts, and two test files. Prose and comments only, no behaviour; the
quotes are untouched, because what was said about bot pacing is the part worth
keeping.

Left alone deliberately: nineteen older mentions in entries about v0.7.5, v0.7.6
and v0.7.8 and in TODO.md, since rewriting a changelog after the fact makes the
record less true; and scripts/deploy-web.ts, where the host is the functional
default for FB_URL rather than prose — turning that into a required variable
changes how deploying works and wants deciding on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
2026-09-09 21:43:06 -04:00
Jesse.MarkowitzandClaude Opus 5 ff629c0708 v0.8.0.3 — Skip on the left, a caption that says who, and a clock that stops
stretching

Three things from playing v0.8.0.2, all about the row rather than the mechanism.

Skip was on the far right and a player's eye is on the countdown. Moved to the
left, in front of the count.

The caption said what but never who. 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 introduces itself now: "The Division:
Mainline phase". A player's move already carries its name from record(), so it is
left alone. The row was also hiding one step early, because it showed only while
behind > 0 — which goes false exactly when the last step of a burst goes up, so the
step most likely to be read lost its caption.

And the speed control was stretching the clock along with the players. It was not
his own move being replayed — own moves have cost nothing since v0.8.0.1 — it was
the phases behind it, which put 105 seconds of clock-ticking into a 5x game. pace
now scales a player's move and leaves a phase at its tabled beat, which is what the
control has always claimed to do. Off still means off for both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
2026-09-09 21:06:22 -04:00
Jesse.MarkowitzandClaude Opus 5 c10f52791e v0.8.0.2 — the speed control that was only ever a URL parameter, and a Day-end
contradiction

Two things found by playing v0.8.0.1, neither in the mechanism itself.

?pace= never worked. index.html's doors are play.html?lobby and
play.html?solitaire, so arriving through the splash replaces the query string and
the play page only ever saw ?lobby — a whole game was played at 1x while believing
it was at 7x. v0.8.0 shipped that parameter as the only way to change speed and the
game's own front door destroyed it. There is a control on the play screen now,
beside zoom, persisted per viewer; the doors carry pace through as well, so the URL
lever is honest for handing two playtesters different speeds. PACE_LEVELS moved to
sim/pacing.ts with DWELL and MAX_PACE — the tuning surface in one file, and
testable. The committed default is unchanged: what it should be is a question for a
game played at a speed that took effect.

And the Day-end dialog said "0 today, 2 in all". advance.ts increments the Day and
then zeroes collisionsToday, and noteDayEnd() fires when the Day goes up — so the
dialog reporting the Day that just finished was drawn from the very frame in which
that Day's count was reset. Reproduced on four of five seeds before changing
anything. The count is captured at the rollover now; it is not derivable on the
client, because in multiplayer the push announcing the new Day is the same push
that carries the reset. And "today" was the wrong word regardless: it names the Day
instead — "Collisions: 2 on Day 1, 2 in all".

Unrelated to v0.8.0 — that one has been wrong since the dialog was built for
Gitea#10, and needed somebody to play a Day with a collision in it and then read
the summary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
2026-09-09 20:08:39 -04:00
Jesse.MarkowitzandClaude Opus 5 0cfeb4c496 v0.8.0.1 — bot play was way too fast, and the last step never got its moment
Two things from the first real play on phoenix.local. One bug: busy() was
pending.length > 0, so the final step of a burst reported the queue idle the
instant it was shown — the district panel snapped back to the viewer's own board
and the countdown row vanished before either could be read.

And calibration. "Start at 1s and tune down" was applied to switching, while a
250ms action tier was invented beside it — fine for a switching burst, wrong for
the common case, since switching is not legal until there is track down. A real
early-game bot turn measured 750ms end to end. Actions are 700ms now, and
localOps.choose moved out of bookkeeping: it is the line announcing what a bot is
about to do, and at zero dwell nobody ever saw it.

The viewer's own moves now cost nothing — their board comes from their own Frame,
so holding their click only delayed the thing they wanted to watch. And pace
supports 2 and 3 as asked, bounded by MAX_PACE so a typo cannot look like a
frozen board; every tier scales together, so the weighting survives any speed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
2026-09-09 17:20:18 -04:00
21 changed files with 1354 additions and 61 deletions
+383
View File
@@ -19,6 +19,389 @@ 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 ## 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 **Watching the table.** TODO #13, #15 and #18, which is Gitea#20 steps 2-4 pointed at a seated
+7 -6
View File
@@ -361,14 +361,15 @@ Introduce dedicated allow-listed types. Do not derive them with `Omit<Frame, ...
> **Read those two, not this**, when building steps 2-7. The differences that matter: > **Read those two, not this**, when building steps 2-7. The differences that matter:
> >
> - **The shape is FLAT, not grouped.** There is no `clock`, `config`, `scoring` or `deckCounts` > - **The shape is FLAT, not grouped.** There is no `clock`, `config`, `scoring` or `deckCounts`
> object. Their contents sit at the top level. **All 37 properties, which is the same list as > 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`, > `test/redaction.test.ts`'s allow-list** — `day`, `stage`, `clock` (a time string), `phase`,
> `phaseKey`, `actor`, `superintendent`, `deck`, `departments`, `departmentsWhat`, > `phaseKey`, `actor`, `superintendent`, `deck`, `departments`, `departmentsWhat`,
> `departmentDepth`, `salvage`, `yards`, `timetable`, `timetableWhat`, `houseRules`, `mode`, > `departmentDepth`, `salvage`, `yards`, `timetable`, `timetableWhat`, `houseRules`, `mode`,
> `optionalRules`, `days`, `minCombinedRevenue`, `maxCollisionsPerDay`, `maxCollisionsTotal`, > `optionalRules`, `days`, `minCombinedRevenue`, `maxCollisionsPerDay`, `maxCollisionsTotal`,
> `collisionsToday`, `collisionsTotal`, `status`, `outcome`, `extraDays`, `extensionVotes`, > `collisionsToday`, `collisionsPrevDay`, `collisionsTotal`, `status`, `outcome`, `extraDays`,
> `extensionVotes`,
> `official`, `tally`, `players`, `openingRolls`, `trains`, `crewTrays`, `queued`, `division`, > `official`, `tally`, `players`, `openingRolls`, `trains`, `crewTrays`, `queued`, `division`,
> `districts`. The first 35 come from `projectSharedTable`; `division` and `districts` are added > `districts`. The first 36 come from `projectSharedTable`; `division` and `districts` are added
> by `PublicFrame` itself. > by `PublicFrame` itself.
> - **`protocolVersion` was NOT built** and exists nowhere in the repo. **Decided 2026-09-09: add it > - **`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, > in step 2.** `display.json` carries its own `schemaVersion`, and the SSE wire format is a second,
@@ -887,7 +888,7 @@ Cover:
## Step 5 — Minimal visual-only Jitsi engine ## Step 5 — Minimal visual-only Jitsi engine
> **v0.9.0 — DEFERRED (2026-09-09).** Steps 5-7 are the Jitsi publisher and are explicitly held > **v0.9.0 — DEFERRED (2026-09-09).** Steps 5-7 are the Jitsi publisher and are explicitly held
> off until 0.8.0 ships and Chromium has been measured on `phoenix.local`. Nothing below has been > 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 > 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 > 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 > SPLIT), and adding Chromium takes the `.s9pk` from 63 MB to several hundred against a package
@@ -1027,7 +1028,7 @@ Port/adapt the sibling repository’s proven tests for:
## Step 6 — Chromium publisher supervisor ## Step 6 — Chromium publisher supervisor
> **v0.9.0 — DEFERRED (2026-09-09).** Steps 5-7 are the Jitsi publisher and are explicitly held > **v0.9.0 — DEFERRED (2026-09-09).** Steps 5-7 are the Jitsi publisher and are explicitly held
> off until 0.8.0 ships and Chromium has been measured on `phoenix.local`. Nothing below has been > 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 > 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 > 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 > SPLIT), and adding Chromium takes the `.s9pk` from 63 MB to several hundred against a package
@@ -1160,7 +1161,7 @@ Use fake child processes and fake control sockets to test:
## Step 7 — Configuration, lifecycle, packaging, and observability ## Step 7 — Configuration, lifecycle, packaging, and observability
> **v0.9.0 — DEFERRED (2026-09-09).** Steps 5-7 are the Jitsi publisher and are explicitly held > **v0.9.0 — DEFERRED (2026-09-09).** Steps 5-7 are the Jitsi publisher and are explicitly held
> off until 0.8.0 ships and Chromium has been measured on `phoenix.local`. Nothing below has been > 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 > 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 > 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 > SPLIT), and adding Chromium takes the `.s9pk` from 63 MB to several hundred against a package
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "station-master", "name": "station-master",
"version": "0.8.0", "version": "0.8.0.8",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Station Master — a railroad operations game", "description": "Station Master — a railroad operations game",
+3
View File
@@ -1638,6 +1638,9 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
s.clock.day += 1; s.clock.day += 1;
s.clock.stage = 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; s.collisionsToday = 0;
events.push({ type: 'stageBegan', day: s.clock.day, stage: 1 }); events.push({ type: 'stageBegan', day: s.clock.day, stage: 1 });
rotateSeats(s, events); rotateSeats(s, events);
+37 -3
View File
@@ -2242,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 // 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 // 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. // 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 = [[], [], []]; s.decks.departments = [[], [], []];
const order = [...e.order]; const order = [...e.order];
for (const pile of s.decks.departments) { for (const pile of s.decks.departments) {
@@ -2473,7 +2474,15 @@ export function reduce(s: GameState, e: GameEvent): void {
case 'trainScheduled': case 'trainScheduled':
s.timetable[e.slot] = e.trainNumber; s.timetable[e.slot] = e.trainNumber;
s.rngState = e.rngState; 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; break;
case 'carPlacedOnTrain': { case 'carPlacedOnTrain': {
@@ -2930,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. * 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. * 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 { function reshuffleIfDepleted(s: GameState, taking: number): GameEvent | null {
if (s.decks.homeOffice.length > taking) return 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; if (collected.length === 0) return null;
const rng = createRng(s.rngState); const rng = createRng(s.rngState);
return { type: 'deckReshuffled', order: rng.shuffle(collected), rngState: rng.getState() }; return { type: 'deckReshuffled', order: rng.shuffle(collected), rngState: rng.getState() };
+1
View File
@@ -431,6 +431,7 @@ export function createGame(opts: SetupOptions): GameState {
turns: freshTurns(playerCount, MOVES_PER_LOCAL_OPS), turns: freshTurns(playerCount, MOVES_PER_LOCAL_OPS),
movedThisPhase: new Set(), movedThisPhase: new Set(),
collisionsToday: 0, collisionsToday: 0,
collisionsPrevDay: 0,
collisionsTotal: 0, collisionsTotal: 0,
status: 'active', status: 'active',
outcome: null, outcome: null,
+14
View File
@@ -1119,6 +1119,20 @@ export type GameState = {
movedThisPhase: Set<TrayId>; movedThisPhase: Set<TrayId>;
/** §3.4 — resets at the start of each Day; checked against `config.maxCollisionsPerDay`. */ /** §3.4 — resets at the start of each Day; checked against `config.maxCollisionsPerDay`. */
collisionsToday: number; 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`. */ /** §3.4 — never reset; checked against `config.maxCollisionsTotal`. */
collisionsTotal: number; collisionsTotal: number;
/** /**
+101 -12
View File
@@ -37,7 +37,9 @@ export type StepKind = 'switching' | 'action' | 'phase' | 'bookkeeping';
* it is deliberately not the only way to change the pacing. A viewer's own `pace` multiplier * 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 * (`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 * `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". * 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 * 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 * `config` rides along in saves and replays. If it ever moves there, the config field supplies this
@@ -46,8 +48,17 @@ export type StepKind = 'switching' | 'action' | 'phase' | 'bookkeeping';
export const DWELL: Record<StepKind, number> = { export const DWELL: Record<StepKind, number> = {
/** A train physically moving on the board. The thing worth watching, and protected accordingly. */ /** A train physically moving on the board. The thing worth watching, and protected accordingly. */
switching: 1000, switching: 1000,
/** A card, a car or a load changing hands somewhere visible. */ /**
action: 250, * 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. * An automatic phase that DID something — TODO #18.
* *
@@ -107,9 +118,17 @@ export function kindOf(cause: StepCause): StepKind {
case 'redFlag.play': case 'redFlag.play':
return 'action'; return 'action';
// Ending a phase or a turn, choosing what to do, voting. The consequences are worth watching; /**
// the declaration itself is not, and there are more of these than of anything else. * `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': 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 'loadUnload.end':
case 'draw.end': case 'draw.end':
case 'switch.end': case 'switch.end':
@@ -119,9 +138,65 @@ export function kindOf(cause: StepCause): StepKind {
} }
} }
/** How long to show one step, in ms, at a given speed. `pace` of 0 means "do not animate at all". */ /**
* 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 { export function dwellFor(cause: StepCause, pace = 1): number {
return Math.round(DWELL[kindOf(cause)] * Math.max(0, pace)); 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;
} }
/** /**
@@ -134,10 +209,25 @@ export function dwellFor(cause: StepCause, pace = 1): number {
* have to import `DisplayStep` back from the module that imports `StepCause` from it. * have to import `DisplayStep` back from the module that imports `StepCause` from it.
*/ */
export function dwellForStep( export function dwellForStep(
step: { cause: StepCause; lines: readonly unknown[]; frame: { table: object } }, step: { cause: StepCause; player: number | null; lines: readonly unknown[]; frame: { table: object } },
pace = 1, pace = 1,
): number { ): number {
if (step.lines.length > 0) return dwellFor(step.cause, pace); // 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 * 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. * every phase a visible beat", for New Train, the Mainline and the shift change.
@@ -146,12 +236,11 @@ export function dwellForStep(
* silently killed #18: a phase can move trains without saying anything, and those steps were being * 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 * 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 * `advance()` about 4.6 times per intent and most of those merely hand the turn on, so beating on
* all of them would cost a quarter of an hour a game. The phase turning over is the thing a player * all of them would cost a quarter of an hour a game.
* is being shown, and there are about 180 of those in a full game.
*/ */
const table = step.frame.table as Record<string, unknown>; const table = step.frame.table as Record<string, unknown>;
const turned = 'phase' in table || 'phaseKey' in table || 'stage' in table || 'day' in table; const turned = 'phase' in table || 'phaseKey' in table || 'stage' in table || 'day' in table;
return turned ? dwellFor(step.cause, pace) : 0; return turned ? dwellFor(step.cause, speed) : 0;
} }
/** /**
+43
View File
@@ -130,3 +130,46 @@ function need<T>(value: T | undefined, what: string): T {
} }
return value; 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;
}
+3
View File
@@ -437,6 +437,8 @@ export type Frame = {
maxCollisionsPerDay: number; maxCollisionsPerDay: number;
maxCollisionsTotal: number; maxCollisionsTotal: number;
collisionsToday: number; collisionsToday: number;
/** What the Day that just ended finished on — see `collisionsPrevDay` in `engine/state.ts`. */
collisionsPrevDay: number;
collisionsTotal: number; collisionsTotal: number;
status: GameState['status']; status: GameState['status'];
outcome: GameState['outcome']; outcome: GameState['outcome'];
@@ -1594,6 +1596,7 @@ export function projectSharedTable(s: GameState) {
maxCollisionsPerDay: s.config.maxCollisionsPerDay, maxCollisionsPerDay: s.config.maxCollisionsPerDay,
maxCollisionsTotal: s.config.maxCollisionsTotal, maxCollisionsTotal: s.config.maxCollisionsTotal,
collisionsToday: s.collisionsToday, collisionsToday: s.collisionsToday,
collisionsPrevDay: s.collisionsPrevDay,
collisionsTotal: s.collisionsTotal, collisionsTotal: s.collisionsTotal,
status: s.status, status: s.status,
outcome: s.outcome, outcome: s.outcome,
+143 -11
View File
@@ -26,6 +26,7 @@ import { createLocalSession, createRemoteSession } from './session.ts';
import type { PlayerIndex } from '../engine/state.ts'; import type { PlayerIndex } from '../engine/state.ts';
import type { PublicDistrict } from '../sim/view.ts'; import type { PublicDistrict } from '../sim/view.ts';
import { createStepQueue } from './step-queue.ts'; import { createStepQueue } from './step-queue.ts';
import { PACE_LEVELS } from '../sim/pacing.ts';
import { notice, prefillCode, runLobby } from './lobby.ts'; import { notice, prefillCode, runLobby } from './lobby.ts';
import type { LobbyReady } from './lobby.ts'; import type { LobbyReady } from './lobby.ts';
import { import {
@@ -53,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. */ /** 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; 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 * 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 * `SAVE_KEY`. A save is the seed plus the intents and has to stay portable; none of this belongs
@@ -165,7 +167,12 @@ let session: Session;
* on the next step instead of the next game. `?pace=` wins over the saved setting for this session * on the next step instead of the next game. `?pace=` wins over the saved setting for this session
* only. * only.
*/ */
const stepQueue = createStepQueue(() => PACE_OVERRIDE ?? settings.pace); 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. * Pulls whatever the session has for us into the queue. Called on every push, before rendering.
@@ -177,6 +184,20 @@ function drainIntoQueue(): void {
const reset = session.takeDisplayReset(); const reset = session.takeDisplayReset();
if (reset) stepQueue.reset(reset); if (reset) stepQueue.reset(reset);
stepQueue.push(session.takeDisplaySteps()); 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(); if (stepQueue.busy()) startAnimationLoop();
} }
@@ -194,17 +215,23 @@ function drainIntoQueue(): void {
* drawn — from `PublicDistrict`, which `officeSvg` can render as-is because it takes board data and * drawn — from `PublicDistrict`, which `officeSvg` can render as-is because it takes board data and
* has never needed a private viewer. * has never needed a private viewer.
*/ */
function renderWatching(): void { function renderWatching(f?: Frame): void {
const behind = stepQueue.behind(); const behind = stepQueue.behind();
const row = $('watching'); const row = $('watching');
// Collapsed whenever the board is level with the game — which in solitaire is nearly always, and /**
// between turns in multiplayer too. A row that is always there would be a row nobody reads. * VISIBLE WHILE THE BOARD IS BEHIND **OR** STILL SHOWING SOMETHING.
if (behind === 0) { *
* 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; row.hidden = true;
return; return;
} }
row.hidden = false; row.hidden = false;
$('watching-behind').textContent = `${behind} behind`; $('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. * THE CAPTION IS #15, and this is where that item lands rather than as a line of its own.
* *
@@ -213,8 +240,29 @@ function renderWatching(): void {
* The queue IS that, so the caption simply names the step being shown, and the counter beside it * 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. * 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 showing = stepQueue.showing();
$('watching-what').textContent = showing?.lines[0]?.text ?? ''; 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. * SKIP COSTS THE ANIMATION AND NEVER THE INFORMATION.
* *
@@ -282,12 +330,25 @@ function startAnimationLoop(): void {
*/ */
console.error('display queue stopped:', err); console.error('display queue stopped:', err);
animating = false; 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; return;
} }
if (!stepQueue.busy()) { if (!stepQueue.busy()) {
animating = false; animating = false;
// One last render so the "N behind" row collapses the moment the board is level. /**
renderWatching(); * 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; return;
} }
requestAnimationFrame(tick); requestAnimationFrame(tick);
@@ -1257,7 +1318,7 @@ function render(): void {
renderTurnChart(f); renderTurnChart(f);
renderPresence(f); renderPresence(f);
renderWatching(); renderWatching(f);
$('revenue').textContent = String(f.revenue); $('revenue').textContent = String(f.revenue);
/** /**
* THE OBJECTIVE, WITHOUT THE COMMENTARY. * THE OBJECTIVE, WITHOUT THE COMMENTARY.
@@ -1477,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 * 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. * 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); renderYards(f);
@@ -1951,6 +2014,29 @@ function renderActions(
renderEnding(el, f); renderEnding(el, f);
return; 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 // 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). // put its results up unasked again (Gitea#11).
resultsShown = false; resultsShown = false;
@@ -2598,6 +2684,52 @@ function runSolitaireSetup(params: URLSearchParams, hasSave = false, live: Frame
dealBtn.onclick = () => commitNewGame(ss, seedField?.value ?? ''); 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 zoomOutBtn = document.getElementById('zoomout') as HTMLButtonElement | null;
const zoomInBtn = document.getElementById('zoomin') as HTMLButtonElement | null; const zoomInBtn = document.getElementById('zoomin') as HTMLButtonElement | null;
const zoomLabel = document.getElementById('zoomlabel'); const zoomLabel = document.getElementById('zoomlabel');
+84 -8
View File
@@ -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 * 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. * is out of reach, and choosing where to discard is choosing what to put there.
*/ */
export function pilesHtml(f: Frame): string { export function pilesHtml(f: Frame, lit: readonly string[] = []): string {
const pile = (label: string, top: string, depth: number, why: string, extra = '', slot = -1): 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(' · '); const tip = [why, extra].filter(Boolean).join(' · ');
// A Department is a DROP TARGET for a discard. The attribute is always emitted; only the play // 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 // 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. // viewer draws exactly the same markup and nothing there is clickable.
const target = slot >= 0 ? ` data-dept="${slot}"` : ''; 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 ( 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>` + `<div class="pilehd"><span>${esc(label)}</span><span class="depth">${depth}</span></div>` +
`<b>${esc(top)}</b></div>` `<b>${esc(top)}</b></div>`
); );
}; };
return ( 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 f.departments
.map((d, i) => { .map((d, i) => {
const depth = f.departmentDepth[i] ?? 0; const depth = f.departmentDepth[i] ?? 0;
const under = depth - 1; const under = depth - 1;
return pile( return pile(
`dept${i}`,
`Dept ${i + 1}`, `Dept ${i + 1}`,
d, d,
depth, depth,
@@ -81,6 +117,7 @@ export function pilesHtml(f: Frame): string {
}) })
.join('') + .join('') +
pile( pile(
'salvage',
'Salvage', 'Salvage',
f.salvage.top, f.salvage.top,
f.salvage.depth, f.salvage.depth,
@@ -180,7 +217,7 @@ export function dayEndHtml(f: Frame): string {
ahead + ahead +
standingsHtml(f) + standingsHtml(f) +
targetHtml(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 * its config and enforces neither, so reporting a collision budget there would put a rule on
* screen that this game does not have. * screen that this game does not have.
*/ */
function collisionsHtml(f: Frame): string { function collisionsHtml(f: Frame, endedDay?: number): string {
const scoredOnCollisions = const scoredOnCollisions =
(f.mode === 'competitive' || f.mode === 'coop') && (f.mode === 'competitive' || f.mode === 'coop') &&
(f.maxCollisionsTotal > 0 || f.maxCollisionsPerDay > 0); (f.maxCollisionsTotal > 0 || f.maxCollisionsPerDay > 0);
return scoredOnCollisions if (!scoredOnCollisions) return '';
? `<p>Collisions: <b>${f.collisionsToday}</b> today, <b>${f.collisionsTotal}</b> in all.</p>` /**
: ''; * "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} .handcard:focus{outline:2px solid #4d6fa8;outline-offset:1px}
.cardrow.ref .handcard{background:#1c2129;border-style:dashed;border-color:#39424e;color:#b6bec9} .cardrow.ref .handcard{background:#1c2129;border-style:dashed;border-color:#39424e;color:#b6bec9}
.handcard.unplayable{color:#7d8794;border-color:#39424e} .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; .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)} 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 /* THE CARD JUST DRAWN. It sits first in the row, and this says which one that is — three cards that
+13 -1
View File
@@ -125,6 +125,7 @@ section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
.wbehind{font-variant-numeric:tabular-nums;font-weight:700;color:#c9cee0; .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} 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-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} #presence:empty{display:none}
/* division strip */ /* division strip */
#division{display:flex;gap:7px;overflow-x:auto;padding-bottom:4px} #division{display:flex;gap:7px;overflow-x:auto;padding-bottom:4px}
@@ -872,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."> <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> <button id="zoomout" aria-label="Zoom out">−</button><span id="zoomlabel">100%</span><button id="zoomin" aria-label="Zoom in">+</button>
</span> </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="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="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> <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>
@@ -906,9 +915,12 @@ ul.blocked li{padding:2px 0}
and Skip. Empty and collapsed whenever the board is level with the game, which in solitaire is and Skip. Empty and collapsed whenever the board is level with the game, which in solitaire is
almost always. --> almost always. -->
<div id="watching" hidden> <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-behind" class="wbehind"></span>
<span id="watching-what"></span> <span id="watching-what"></span>
<button id="watching-skip" class="ghost" type="button" title="Stop animating and jump the board to where the game actually is. Nothing is lost — every line is already in the History panel.">Skip</button>
</div> </div>
<main> <main>
+23
View File
@@ -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 * 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. * 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'); const mpDoor = document.getElementById('door-multiplayer');
if (mpDoor) { if (mpDoor) {
const close = (): void => { const close = (): void => {
+58 -8
View File
@@ -19,7 +19,8 @@
import type { PublicFrame } from '../sim/view.ts'; import type { PublicFrame } from '../sim/view.ts';
import type { DisplayStep } from '../sim/display-step.ts'; import type { DisplayStep } from '../sim/display-step.ts';
import { applyPublicDelta } from '../sim/public-delta.ts'; import { applyPublicDelta, changedPiles } from '../sim/public-delta.ts';
import type { PileKey } from '../sim/public-delta.ts';
import { dwellForStep } from '../sim/pacing.ts'; import { dwellForStep } from '../sim/pacing.ts';
export type StepQueue = { export type StepQueue = {
@@ -43,22 +44,54 @@ export type StepQueue = {
behind(): number; behind(): number;
/** The last step actually shown, for the caption line (#15). Null before anything has been shown. */ /** The last step actually shown, for the caption line (#15). Null before anything has been shown. */
showing(): DisplayStep | null; 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. */ /** True while there is anything left to show. */
busy(): boolean; busy(): boolean;
}; };
/** `pace` is read on every step rather than captured, so changing the setting takes effect at once. */ /**
export function createStepQueue(pace: () => number = () => 1): StepQueue { * `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 shown: PublicFrame | null = null;
let last: DisplayStep | null = null; let last: DisplayStep | null = null;
let litPiles: readonly PileKey[] = [];
let pending: DisplayStep[] = []; let pending: DisplayStep[] = [];
/** When the step now on screen is due to give way. Null when nothing is waiting. */ /** When the step now on screen is due to give way. Null when nothing is waiting. */
let dueAt: number | null = null; 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. */ /** Applies one step to the displayed board. A step's delta chains off the previous step's frame. */
const show = (step: DisplayStep): void => { const show = (step: DisplayStep): void => {
const before = shown;
shown = applyPublicDelta(shown, step.frame); shown = applyPublicDelta(shown, step.frame);
last = step; 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 { return {
@@ -66,6 +99,8 @@ export function createStepQueue(pace: () => number = () => 1): StepQueue {
shown = frame; shown = frame;
pending = []; pending = [];
dueAt = null; 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 // `last` deliberately survives: a reconnect should not blank the caption line, and the
// sentence describing the most recent action is still true. // sentence describing the most recent action is still true.
}, },
@@ -76,7 +111,10 @@ export function createStepQueue(pace: () => number = () => 1): StepQueue {
advance(now) { advance(now) {
if (pending.length === 0) { if (pending.length === 0) {
dueAt = null; // 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; return false;
} }
// First step of a burst: show it immediately rather than waiting out a dwell for a board the // First step of a burst: show it immediately rather than waiting out a dwell for a board the
@@ -84,7 +122,7 @@ export function createStepQueue(pace: () => number = () => 1): StepQueue {
if (dueAt === null) { if (dueAt === null) {
const first = pending.shift()!; const first = pending.shift()!;
show(first); show(first);
dueAt = now + dwellForStep(first, pace()); dueAt = now + dwell(first);
return true; return true;
} }
let drew = false; let drew = false;
@@ -97,7 +135,7 @@ export function createStepQueue(pace: () => number = () => 1): StepQueue {
while (pending.length > 0 && now >= dueAt) { while (pending.length > 0 && now >= dueAt) {
const next = pending.shift()!; const next = pending.shift()!;
show(next); show(next);
dueAt = dueAt + dwellForStep(next, pace()); dueAt = dueAt + dwell(next);
drew = true; drew = true;
} }
if (pending.length === 0 && now >= dueAt) dueAt = null; if (pending.length === 0 && now >= dueAt) dueAt = null;
@@ -113,8 +151,20 @@ export function createStepQueue(pace: () => number = () => 1): StepQueue {
}, },
current: () => shown, current: () => shown,
behind: () => pending.filter((s) => dwellForStep(s, pace()) > 0).length, behind: () => pending.filter((s) => dwell(s) > 0).length,
showing: () => last, showing: () => last,
busy: () => pending.length > 0, 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,
}; };
} }
+59 -1
View File
@@ -159,6 +159,60 @@ describe('Local Operations: drawing (§6.2)', () => {
assert.notEqual(s.decks.departments[1]![0], target, 'refilled with the same card'); 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', () => { 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 // §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 // 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.ok);
assert.ok(r.events.some((e) => e.type === 'deckReshuffled'), 'no reshuffle was emitted'); 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.homeOffice.length > 0, 'the deck must be re-established');
assert.ok( assert.ok(
s.decks.departments.every((p) => p.length === 1), s.decks.departments.every((p) => p.length === 1),
+127 -7
View File
@@ -14,7 +14,7 @@ import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { DWELL, dwellFor, dwellForStep, kindOf, watchableCount } from '../src/sim/pacing.ts'; 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 { StepKind } from '../src/sim/pacing.ts';
import type { Intent } from '../src/engine/intents.ts'; import type { Intent } from '../src/engine/intents.ts';
@@ -50,7 +50,13 @@ describe('pacing — dwell by kind', () => {
assert.equal(kindOf('draw.end'), 'bookkeeping'); assert.equal(kindOf('draw.end'), 'bookkeeping');
assert.equal(kindOf('loadUnload.end'), 'bookkeeping'); assert.equal(kindOf('loadUnload.end'), 'bookkeeping');
assert.equal(kindOf('switch.end'), 'bookkeeping'); assert.equal(kindOf('switch.end'), 'bookkeeping');
assert.equal(kindOf('localOps.choose'), '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.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'); assert.equal(DWELL.bookkeeping, 0, 'bookkeeping must cost the player no time at all');
@@ -63,6 +69,30 @@ describe('pacing — dwell by kind', () => {
assert.equal(dwellFor('switch.move'), 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', () => { it('scales with the viewer\'s pace, and 0 turns it off', () => {
assert.equal(dwellFor('switch.move', 1), 1000); assert.equal(dwellFor('switch.move', 1), 1000);
assert.equal(dwellFor('switch.move', 0.5), 500); assert.equal(dwellFor('switch.move', 0.5), 500);
@@ -90,6 +120,37 @@ describe('pacing — dwell by kind', () => {
assert.equal(watchableCount(queue, 0), 0, 'with animation off, nothing is behind'); 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', () => { 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 * Both obvious rules were wrong, so both are pinned. "No narration, no dwell" flashed past
@@ -97,28 +158,87 @@ describe('pacing — dwell by kind', () => {
* that changed the board" beat on every turn hand-off — `submit()` steps `advance()` about 4.6 * 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. * times per intent — which came to a quarter of an hour a game.
*/ */
const silent = { cause: 'phase' as const, lines: [] as string[] }; 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: { 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: {} } }), 0, 'a step that changed nothing shows nothing');
assert.equal(dwellForStep({ ...silent, frame: { table: { phase: 'mainline' } } }), DWELL.phase); assert.equal(dwellForStep({ ...silent, frame: { table: { phase: 'mainline' } } }), DWELL.phase);
assert.equal(dwellForStep({ ...silent, frame: { table: { stage: 4 } } }), 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. // Narration always earns the dwell of whatever caused it, clock or no clock.
assert.equal( assert.equal(
dwellForStep({ cause: 'switch.move', lines: ['moved'], frame: { table: {} } }), dwellForStep({ cause: 'switch.move', player: 1, lines: ['moved'], frame: { table: {} } }),
DWELL.switching, 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', () => { 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 // Six moves is the engine's cap per crew ("N of 6 Moves left"), so this is the worst ordinary
// case for one crew and the arithmetic the design promised: ~6s to watch a whole exercise. // case for one crew: the announcement, six moves, and an end that shows nothing.
const turn: Intent['type'][] = [ const turn: Intent['type'][] = [
'localOps.choose', 'localOps.choose',
...Array<Intent['type']>(6).fill('switch.move'), ...Array<Intent['type']>(6).fill('switch.move'),
'switch.end', 'switch.end',
]; ];
const total = turn.reduce((ms, i) => ms + dwellFor(i), 0); const total = turn.reduce((ms, i) => ms + dwellFor(i), 0);
assert.equal(total, 6000); assert.equal(total, DWELL.action + 6 * DWELL.switching);
assert.equal(watchableCount(turn), 6, 'the choose and the end are not things to watch'); 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');
}); });
}); });
+71 -1
View File
@@ -17,7 +17,7 @@ import type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.ts'
import { applyIntent } from '../src/engine/apply.ts'; import { applyIntent } from '../src/engine/apply.ts';
import { currentActorOfState, publicSnapshot } from '../src/sim/view.ts'; import { currentActorOfState, publicSnapshot } from '../src/sim/view.ts';
import type { PublicFrame } from '../src/sim/view.ts'; import type { PublicFrame } from '../src/sim/view.ts';
import { applyPublicDelta, deltaPublicFrame } from '../src/sim/public-delta.ts'; import { applyPublicDelta, changedPiles, deltaPublicFrame } from '../src/sim/public-delta.ts';
const config: GameConfig = { const config: GameConfig = {
mode: 'competitive', mode: 'competitive',
@@ -184,3 +184,73 @@ describe('public frame delta', () => {
}); });
type PublicIndexed = { seat: number; before: PublicFrame; after: PublicFrame }; 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)), []);
});
});
+3
View File
@@ -524,6 +524,9 @@ describe('#91 — nothing private survives serialisation, in any state', () => {
// The rules the game was dealt under, and the score. // The rules the game was dealt under, and the score.
'houseRules', 'mode', 'optionalRules', 'days', 'minCombinedRevenue', 'houseRules', 'mode', 'optionalRules', 'days', 'minCombinedRevenue',
'maxCollisionsPerDay', 'maxCollisionsTotal', 'collisionsToday', 'collisionsTotal', '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', 'status', 'outcome', 'extraDays', 'extensionVotes', 'official', 'tally',
// Names, seats, revenue and HAND SIZE — never hand contents. // Names, seats, revenue and HAND SIZE — never hand contents.
'players', 'players',
+77 -1
View File
@@ -87,7 +87,8 @@ describe('the step queue', () => {
q.reset(baseline(1917398)); q.reset(baseline(1917398));
// Only the bookkeeping: it must all collapse into a single advance. // Only the bookkeeping: it must all collapse into a single advance.
const bookkeeping = steps.filter((s) => s.cause.endsWith('.end') || s.cause === 'localOps.choose'); // `.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'); assert.ok(bookkeeping.length > 10, 'not enough bookkeeping steps to prove the collapse');
q.push(bookkeeping); q.push(bookkeeping);
q.advance(0); q.advance(0);
@@ -173,6 +174,62 @@ describe('the step queue', () => {
assert.equal(half.behind(), 1, 'at half pace, half the dwell should have advanced one step'); 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', () => { 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. * A reconnecting client holds steps whose deltas chain off a baseline the server has moved past.
@@ -194,6 +251,25 @@ describe('the step queue', () => {
assert.ok(q.showing() !== null, 'the caption should survive a reset'); 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', () => { it('draws nothing before a reset has arrived', () => {
const q = createStepQueue(); const q = createStepQueue();
assert.equal(q.current(), null); assert.equal(q.current(), null);
+103 -1
View File
@@ -23,7 +23,7 @@ import { variantsFor } from '../src/engine/track.ts';
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts'; import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
import type { DivisionView } from '../src/sim/view.ts'; import type { DivisionView } from '../src/sim/view.ts';
import { ENHANCEMENT_RULES, STAGES_PER_DAY } from '../src/engine/content.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 { turnChartHtml } from '../src/sim/turnchart.ts';
import { fieldSelectors } from '../src/web/settings-form.ts'; import { fieldSelectors } from '../src/web/settings-form.ts';
import { record, renderHtml } from '../src/sim/replay.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}`); 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', () => { it('counts the last Day as the last Day rather than promising more', () => {
const html = dayEndHtml(frameAt(6)); const html = dayEndHtml(frameAt(6));
assert.ok(html.includes('Day 5 has ended'), 'the final Day is misnamed'); assert.ok(html.includes('Day 5 has ended'), 'the final Day is misnamed');