Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0e5091824 | ||
|
|
64e8ce584f | ||
|
|
3fca325699 | ||
|
|
fc40fc39ed | ||
|
|
ff629c0708 | ||
|
|
c10f52791e |
+287
-1
@@ -19,9 +19,295 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
||||
|
||||
---
|
||||
|
||||
## 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 `phoenix.local` and played within the hour;
|
||||
**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.
|
||||
|
||||
@@ -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:
|
||||
>
|
||||
> - **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`,
|
||||
> `phaseKey`, `actor`, `superintendent`, `deck`, `departments`, `departmentsWhat`,
|
||||
> `departmentDepth`, `salvage`, `yards`, `timetable`, `timetableWhat`, `houseRules`, `mode`,
|
||||
> `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`,
|
||||
> `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.
|
||||
> - **`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,
|
||||
@@ -887,7 +888,7 @@ Cover:
|
||||
## Step 5 — Minimal visual-only Jitsi engine
|
||||
|
||||
> **v0.9.0 — DEFERRED (2026-09-09).** Steps 5-7 are the Jitsi publisher and are explicitly held
|
||||
> off until 0.8.0 ships and Chromium has been measured on `phoenix.local`. Nothing below has been
|
||||
> off until 0.8.0 ships and Chromium has been measured on the target hardware. Nothing below has been
|
||||
> re-verified against the current code; it was written 2026-08-27. Two things will have moved by
|
||||
> the time it is picked up: the control channel is **SSE + POST, not a WebSocket** (§ THE RELEASE
|
||||
> SPLIT), and adding Chromium takes the `.s9pk` from 63 MB to several hundred against a package
|
||||
@@ -1027,7 +1028,7 @@ Port/adapt the sibling repository’s proven tests for:
|
||||
## Step 6 — Chromium publisher supervisor
|
||||
|
||||
> **v0.9.0 — DEFERRED (2026-09-09).** Steps 5-7 are the Jitsi publisher and are explicitly held
|
||||
> off until 0.8.0 ships and Chromium has been measured on `phoenix.local`. Nothing below has been
|
||||
> off until 0.8.0 ships and Chromium has been measured on the target hardware. Nothing below has been
|
||||
> re-verified against the current code; it was written 2026-08-27. Two things will have moved by
|
||||
> the time it is picked up: the control channel is **SSE + POST, not a WebSocket** (§ THE RELEASE
|
||||
> SPLIT), and adding Chromium takes the `.s9pk` from 63 MB to several hundred against a package
|
||||
@@ -1160,7 +1161,7 @@ Use fake child processes and fake control sockets to test:
|
||||
## Step 7 — Configuration, lifecycle, packaging, and observability
|
||||
|
||||
> **v0.9.0 — DEFERRED (2026-09-09).** Steps 5-7 are the Jitsi publisher and are explicitly held
|
||||
> off until 0.8.0 ships and Chromium has been measured on `phoenix.local`. Nothing below has been
|
||||
> off until 0.8.0 ships and Chromium has been measured on the target hardware. Nothing below has been
|
||||
> re-verified against the current code; it was written 2026-08-27. Two things will have moved by
|
||||
> the time it is picked up: the control channel is **SSE + POST, not a WebSocket** (§ THE RELEASE
|
||||
> SPLIT), and adding Chromium takes the `.s9pk` from 63 MB to several hundred against a package
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "station-master",
|
||||
"version": "0.8.0.1",
|
||||
"version": "0.8.0.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Station Master — a railroad operations game",
|
||||
|
||||
@@ -1638,6 +1638,9 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
|
||||
s.clock.day += 1;
|
||||
s.clock.stage = 1;
|
||||
// Captured BEFORE the reset: the Day-end dialog reports the Day that just finished, and it is
|
||||
// drawn from the frame this rollover produces. See `collisionsPrevDay` in `state.ts`.
|
||||
s.collisionsPrevDay = s.collisionsToday;
|
||||
s.collisionsToday = 0;
|
||||
events.push({ type: 'stageBegan', day: s.clock.day, stage: 1 });
|
||||
rotateSeats(s, events);
|
||||
|
||||
@@ -431,6 +431,7 @@ export function createGame(opts: SetupOptions): GameState {
|
||||
turns: freshTurns(playerCount, MOVES_PER_LOCAL_OPS),
|
||||
movedThisPhase: new Set(),
|
||||
collisionsToday: 0,
|
||||
collisionsPrevDay: 0,
|
||||
collisionsTotal: 0,
|
||||
status: 'active',
|
||||
outcome: null,
|
||||
|
||||
@@ -1119,6 +1119,20 @@ export type GameState = {
|
||||
movedThisPhase: Set<TrayId>;
|
||||
/** §3.4 — resets at the start of each Day; checked against `config.maxCollisionsPerDay`. */
|
||||
collisionsToday: number;
|
||||
/**
|
||||
* What `collisionsToday` held for the Day that just ENDED — captured at the rollover, immediately
|
||||
* before the reset.
|
||||
*
|
||||
* The Day-end dialog exists to report the Day that finished, and it is drawn from the frame AFTER
|
||||
* the rollover, because that is the frame whose `day` went up. So it read `collisionsToday` as 0 no
|
||||
* matter what had happened: Jesse, 2026-09-09, at the end of a Day 1 with two collisions in it —
|
||||
* "it shows a total of two collisions, but zero today ... that does seem to be a contradiction".
|
||||
*
|
||||
* NOT DERIVABLE ON THE CLIENT. A Day turns over inside the phases that run themselves, so in
|
||||
* multiplayer the push that reports the new Day is the same push that reports the reset — a client
|
||||
* may never see the ended Day's final count to remember it.
|
||||
*/
|
||||
collisionsPrevDay: number;
|
||||
/** §3.4 — never reset; checked against `config.maxCollisionsTotal`. */
|
||||
collisionsTotal: number;
|
||||
/**
|
||||
|
||||
+62
-9
@@ -54,7 +54,7 @@ export const DWELL: Record<StepKind, number> = {
|
||||
*
|
||||
* 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 `phoenix.local`: *"bot play was way too fast. I briefly saw
|
||||
* 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.
|
||||
*/
|
||||
@@ -143,10 +143,27 @@ export function kindOf(cause: StepCause): StepKind {
|
||||
*
|
||||
* `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. Ten is far beyond any speed anyone would choose (2 and
|
||||
* 3 are the ones actually asked for) and well short of unusable.
|
||||
* 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 = 10;
|
||||
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.
|
||||
@@ -160,6 +177,28 @@ export function dwellFor(cause: StepCause, pace = 1): number {
|
||||
return Math.round(DWELL[kindOf(cause)] * Math.min(MAX_PACE, Math.max(0, pace)));
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of the speed control a PHASE gets — damped, not the full multiplier.
|
||||
*
|
||||
* Phases were pinned at their tabled beat in v0.8.0.3, because scaling them with everything else put
|
||||
* a wall of clock-ticking after a player's own move. That was right about the cost and wrong about
|
||||
* the need: at 10× the caption row goes past faster than the sentence on it can be read. Jesse,
|
||||
* 2026-09-10: *"phases displayed on the upper line go by too quickly still. Should be 4 times as
|
||||
* long — at a guess. Maybe use the speed multiplier for that too?"*
|
||||
*
|
||||
* So they scale, at a third of the rate. That lands exactly on his guess — 10× gives a phase four
|
||||
* times its tabled beat — while leaving 1× untouched, and it stays affordable because phase beats
|
||||
* cluster rather than accumulate: measured over 60 pushes, a push carries **1.0 phase beat on
|
||||
* average and 4 at worst**, so the wait after a move goes to ~2.4s typical and ~10s at its very
|
||||
* worst rather than the minutes a full multiplier would have cost.
|
||||
*
|
||||
* Below 1× it simply follows the multiplier: somebody asking for everything faster means the phases
|
||||
* too.
|
||||
*/
|
||||
function phaseSpeed(pace: number): number {
|
||||
return pace <= 1 ? pace : 1 + (pace - 1) / 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* How long to show one STEP — the form the queue actually uses.
|
||||
*
|
||||
@@ -170,10 +209,25 @@ export function dwellFor(cause: StepCause, pace = 1): number {
|
||||
* have to import `DisplayStep` back from the module that imports `StepCause` from it.
|
||||
*/
|
||||
export function dwellForStep(
|
||||
step: { cause: StepCause; lines: readonly unknown[]; frame: { table: object } },
|
||||
step: { cause: StepCause; player: number | null; lines: readonly unknown[]; frame: { table: object } },
|
||||
pace = 1,
|
||||
): 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
|
||||
* every phase a visible beat", for New Train, the Mainline and the shift change.
|
||||
@@ -182,12 +236,11 @@ export function dwellForStep(
|
||||
* silently killed #18: a phase can move trains without saying anything, and those steps were being
|
||||
* flashed past. "Anything that changed the board" is wrong the other way: `submit()` steps
|
||||
* `advance()` about 4.6 times per intent and most of those merely hand the turn on, so beating on
|
||||
* all of them would cost a quarter of an hour a game. The phase turning over is the thing a player
|
||||
* is being shown, and there are about 180 of those in a full game.
|
||||
* all of them would cost a quarter of an hour a game.
|
||||
*/
|
||||
const table = step.frame.table as Record<string, unknown>;
|
||||
const turned = 'phase' in table || 'phaseKey' in table || 'stage' in table || 'day' in table;
|
||||
return turned ? dwellFor(step.cause, pace) : 0;
|
||||
return turned ? dwellFor(step.cause, speed) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -130,3 +130,46 @@ function need<T>(value: T | undefined, what: string): T {
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** A face-up or face-down pile a card can move to or from, as the display addresses it. */
|
||||
export type PileKey = 'home' | 'salvage' | `dept${number}`;
|
||||
|
||||
/**
|
||||
* WHICH PILES A STEP MOVED — derived, never sent.
|
||||
*
|
||||
* The receiver already holds the frame before a step and the frame after it, so which pile changed
|
||||
* is a diff rather than something the wire has to carry. That matters twice over: nothing is added
|
||||
* to the protocol, and it cannot drift out of step with the projection the way a hand-maintained
|
||||
* hint would.
|
||||
*
|
||||
* WHY IT IS NEEDED AT ALL. A player watching somebody else draw a card sees seven seconds of an
|
||||
* unchanged board — the step holds the screen, and the only thing that moved is a number in a panel
|
||||
* they were not looking at. Jesse, playing v0.8.0.4 at 10×: *"many operations still occurred too fast
|
||||
* for me to see"*, which was never about duration. Lighting the pile is what tells the eye where.
|
||||
*
|
||||
* WHAT EACH ACTION MOVES, measured across four seeds rather than reasoned about:
|
||||
*
|
||||
* | intent | piles |
|
||||
* | ----------------------- | -------------------------------------------------------- |
|
||||
* | `draw.fromHomeOffice` | `home` — the COUNT only; the card itself stays private |
|
||||
* | `draw.fromDepartment` | that `dept`, and `home` too when the pile refills from it |
|
||||
* | `card.discard` | that `dept` |
|
||||
* | `card.play` | `salvage`, or nothing here when it lands on the board |
|
||||
* | switching, new trains | nothing here — those show on the board itself |
|
||||
*/
|
||||
export function changedPiles(before: PublicFrame | null, after: PublicFrame): PileKey[] {
|
||||
if (before === null) return [];
|
||||
const out: PileKey[] = [];
|
||||
if (before.deck !== after.deck) out.push('home');
|
||||
after.departmentDepth.forEach((depth, i) => {
|
||||
// The TOP as well as the depth: taking the face-up card and replacing it leaves the count alone
|
||||
// and changes the card everybody can see, which is the half that matters to a watcher.
|
||||
if (before.departmentDepth[i] !== depth || before.departments[i] !== after.departments[i]) {
|
||||
out.push(`dept${i}`);
|
||||
}
|
||||
});
|
||||
if (before.salvage.depth !== after.salvage.depth || before.salvage.top !== after.salvage.top) {
|
||||
out.push('salvage');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -437,6 +437,8 @@ export type Frame = {
|
||||
maxCollisionsPerDay: number;
|
||||
maxCollisionsTotal: number;
|
||||
collisionsToday: number;
|
||||
/** What the Day that just ended finished on — see `collisionsPrevDay` in `engine/state.ts`. */
|
||||
collisionsPrevDay: number;
|
||||
collisionsTotal: number;
|
||||
status: GameState['status'];
|
||||
outcome: GameState['outcome'];
|
||||
@@ -1594,6 +1596,7 @@ export function projectSharedTable(s: GameState) {
|
||||
maxCollisionsPerDay: s.config.maxCollisionsPerDay,
|
||||
maxCollisionsTotal: s.config.maxCollisionsTotal,
|
||||
collisionsToday: s.collisionsToday,
|
||||
collisionsPrevDay: s.collisionsPrevDay,
|
||||
collisionsTotal: s.collisionsTotal,
|
||||
status: s.status,
|
||||
outcome: s.outcome,
|
||||
@@ -1788,6 +1791,23 @@ export function snapshot(
|
||||
|
||||
/** A card id turned into something a person can read. */
|
||||
export function cardName(s: GameState, id: string): string {
|
||||
/**
|
||||
* A SCHEDULED TRAIN IS IN THE SALVAGE YARD UNDER A SYNTHETIC ID, and without this the pile that is
|
||||
* supposed to be face up reads "a card".
|
||||
*
|
||||
* `trainScheduled` pushes `train-<number>` rather than the id of the card that was played
|
||||
* (`apply.ts`), so there is nothing in `s.cards` to look up — and since a train is scheduled
|
||||
* several times a Day, that synthetic id is on top of the Salvage Yard most of the time. Reported
|
||||
* by Jesse 2026-09-10 as "why is salvage deck not face up. I should see the card played onto
|
||||
* salvage": it was face up all along and simply had nothing to say.
|
||||
*
|
||||
* Resolved here rather than in the engine because this is a NAME, which is this file's job. Whether
|
||||
* the engine should be pushing a real card id instead is a separate question with a separate
|
||||
* consequence — the Salvage Yard is swept back into the draw deck when it runs out — and is filed
|
||||
* rather than answered in passing.
|
||||
*/
|
||||
const scheduled = /^train-(\d+)$/.exec(id);
|
||||
if (scheduled) return `Train ${scheduled[1]}`;
|
||||
const k = s.cards.get(id)?.kind;
|
||||
if (!k) return 'a card';
|
||||
switch (k.kind) {
|
||||
|
||||
+137
-10
@@ -26,6 +26,7 @@ import { createLocalSession, createRemoteSession } from './session.ts';
|
||||
import type { PlayerIndex } from '../engine/state.ts';
|
||||
import type { PublicDistrict } from '../sim/view.ts';
|
||||
import { createStepQueue } from './step-queue.ts';
|
||||
import { PACE_LEVELS } from '../sim/pacing.ts';
|
||||
import { notice, prefillCode, runLobby } from './lobby.ts';
|
||||
import type { LobbyReady } from './lobby.ts';
|
||||
import {
|
||||
@@ -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. */
|
||||
const ZOOM_LEVELS = [0.75, 1, 1.25, 1.5] as const;
|
||||
|
||||
|
||||
/**
|
||||
* Small persisted preferences, kept in a `localStorage` key of their own — separate from
|
||||
* `SAVE_KEY`. A save is the seed plus the intents and has to stay portable; none of this belongs
|
||||
@@ -182,6 +184,20 @@ function drainIntoQueue(): void {
|
||||
const reset = session.takeDisplayReset();
|
||||
if (reset) stepQueue.reset(reset);
|
||||
stepQueue.push(session.takeDisplaySteps());
|
||||
/**
|
||||
* NOWHERE TO ANIMATE MEANS DO NOT QUEUE AT ALL.
|
||||
*
|
||||
* Without `requestAnimationFrame` nothing ever advances the queue, so `busy()` would stay true for
|
||||
* good — and since "Your Move" is now put away while the board is catching up, that would hide a
|
||||
* player's own actions permanently, leaving Skip as the only way to play the game. Drawing
|
||||
* everything at once is exactly what `pace = 0` does deliberately, so that is the honest fallback
|
||||
* rather than a broken page. Caught by `test/web.test.ts`, whose DOM stub has no `rAF` — the same
|
||||
* stub that has been proving this page still starts since long before any of this existed.
|
||||
*/
|
||||
if (typeof requestAnimationFrame !== 'function') {
|
||||
stepQueue.skip();
|
||||
return;
|
||||
}
|
||||
if (stepQueue.busy()) startAnimationLoop();
|
||||
}
|
||||
|
||||
@@ -199,17 +215,23 @@ function drainIntoQueue(): void {
|
||||
* drawn — from `PublicDistrict`, which `officeSvg` can render as-is because it takes board data and
|
||||
* has never needed a private viewer.
|
||||
*/
|
||||
function renderWatching(): void {
|
||||
function renderWatching(f?: Frame): void {
|
||||
const behind = stepQueue.behind();
|
||||
const row = $('watching');
|
||||
// Collapsed whenever the board is level with the game — which in solitaire is nearly always, and
|
||||
// between turns in multiplayer too. A row that is always there would be a row nobody reads.
|
||||
if (behind === 0) {
|
||||
/**
|
||||
* VISIBLE WHILE THE BOARD IS BEHIND **OR** STILL SHOWING SOMETHING.
|
||||
*
|
||||
* It used to hide the moment `behind` hit zero — which is the moment the LAST step of a burst goes
|
||||
* up, so the one step a player was most likely to be reading about lost its caption. Collapsed
|
||||
* otherwise: in solitaire that is nearly always, and between turns in multiplayer too, and a row
|
||||
* that is always there is a row nobody reads.
|
||||
*/
|
||||
if (behind === 0 && !stepQueue.busy()) {
|
||||
row.hidden = true;
|
||||
return;
|
||||
}
|
||||
row.hidden = false;
|
||||
$('watching-behind').textContent = `${behind} 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.
|
||||
*
|
||||
@@ -218,8 +240,29 @@ function renderWatching(): void {
|
||||
* The queue IS that, so the caption simply names the step being shown, and the counter beside it
|
||||
* says how much of the wait is left.
|
||||
*/
|
||||
/**
|
||||
* WHO, THEN WHAT — Jesse, 2026-09-09: *"it didn't tell me what the actual action was, like who I
|
||||
* was waiting on or what they were doing. I knew I was behind, but I wasn't sure what I was
|
||||
* supposed to be looking for."*
|
||||
*
|
||||
* The caption was there; it was the wrong half of the sentence. Half the waiting is automatic
|
||||
* phases, whose narration reads "Mainline" — accurate, and no answer at all to "who am I waiting
|
||||
* on". So the name goes first, and a phase says so in as many words rather than leaving the reader
|
||||
* to infer that nobody is acting.
|
||||
*
|
||||
* The narrated line is used as it stands otherwise, because `record()` already prefixes it with the
|
||||
* player — "Player Bot 1 moved Train 3 (−1,−2) → (−1,1)" — so a second name would stutter.
|
||||
*/
|
||||
const showing = stepQueue.showing();
|
||||
$('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.
|
||||
*
|
||||
@@ -287,12 +330,25 @@ function startAnimationLoop(): void {
|
||||
*/
|
||||
console.error('display queue stopped:', err);
|
||||
animating = false;
|
||||
// Do not strand the player behind a queue that can no longer advance: jump the board to the
|
||||
// live position, which brings "Your Move" back with it.
|
||||
try {
|
||||
stepQueue.skip();
|
||||
} catch {
|
||||
/* nothing further to try — the authoritative Frame is still what the rest of the page draws */
|
||||
}
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (!stepQueue.busy()) {
|
||||
animating = false;
|
||||
// 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;
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
@@ -1262,7 +1318,7 @@ function render(): void {
|
||||
|
||||
renderTurnChart(f);
|
||||
renderPresence(f);
|
||||
renderWatching();
|
||||
renderWatching(f);
|
||||
$('revenue').textContent = String(f.revenue);
|
||||
/**
|
||||
* THE OBJECTIVE, WITHOUT THE COMMENTARY.
|
||||
@@ -1482,7 +1538,9 @@ function render(): void {
|
||||
* reach. Drawn like the hand so they read as cards, dashed and unlit because taking one is a draw
|
||||
* action rather than a click on the card itself.
|
||||
*/
|
||||
$('depts').innerHTML = pilesHtml(f);
|
||||
// The pile the move being WATCHED just touched, lit for as long as that step is on screen. Empty
|
||||
// whenever the board is level with the game, or when the move was this player's own.
|
||||
$('depts').innerHTML = pilesHtml(f, stepQueue.busy() ? stepQueue.lit() : []);
|
||||
|
||||
renderYards(f);
|
||||
|
||||
@@ -1956,6 +2014,29 @@ function renderActions(
|
||||
renderEnding(el, f);
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* YOUR MOVE IS PUT AWAY WHILE THE BOARD IS CATCHING UP — Jesse, 2026-09-10: *"your actions should
|
||||
* be hidden while catching up."*
|
||||
*
|
||||
* Two reasons, and the second is the one that changed my mind about it. The board on screen is
|
||||
* behind the game, so a move offered here is a move against a position that has already moved on —
|
||||
* the menu is computed from the CURRENT state and would be acted on while looking at an older one.
|
||||
* And the display had grown to four things demanding attention at once — the district, the history,
|
||||
* the catching-up row and now a lit pile — which is what made the pile highlight so easy to miss.
|
||||
* Taking the action list out of that competition while there is nothing to decide anyway is the
|
||||
* cheapest way to quieten it.
|
||||
*
|
||||
* NOT A BLOCK. Skip is one click away and sits at the left of the row, so the wait is always
|
||||
* voluntary; this replaces the buttons with the reason they are gone, rather than leaving a live
|
||||
* menu over a stale board.
|
||||
*/
|
||||
if (stepQueue.busy()) {
|
||||
el.innerHTML =
|
||||
'<div class="dim">Catching up on what everyone else did — your move is here when the board is ' +
|
||||
'level with the game. <b>Skip</b> jumps straight to it.</div>';
|
||||
return;
|
||||
}
|
||||
// The game is running, so the next ending — an extended Day's, or a fresh game's — is entitled to
|
||||
// put its results up unasked again (Gitea#11).
|
||||
resultsShown = false;
|
||||
@@ -2603,6 +2684,52 @@ function runSolitaireSetup(params: URLSearchParams, hasSave = false, live: Frame
|
||||
dealBtn.onclick = () => commitNewGame(ss, seedField?.value ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* PLAYBACK SPEED — v0.8.0.3, TODO #13.
|
||||
*
|
||||
* Persisted per viewer in `Settings`, so it survives the navigation that was eating `?pace=`. The
|
||||
* queue reads `settings.pace` through a closure on every step, so a change here takes effect on the
|
||||
* very next move rather than the next game.
|
||||
*/
|
||||
const paceSlowerBtn = document.getElementById('paceslower') as HTMLButtonElement | null;
|
||||
const paceFasterBtn = document.getElementById('pacefaster') as HTMLButtonElement | null;
|
||||
const paceLabel = document.getElementById('pacelabel');
|
||||
if (paceSlowerBtn && paceFasterBtn && paceLabel) {
|
||||
const nearestPace = (): number => {
|
||||
// A saved or URL value need not be on the ladder — `?pace=7` and a hand-edited setting are both
|
||||
// legitimate — so the buttons step from whichever preset is closest rather than refusing to move.
|
||||
const want = PACE_OVERRIDE ?? settings.pace;
|
||||
return PACE_LEVELS.reduce((best, p) => (Math.abs(p - want) < Math.abs(best - want) ? p : best), PACE_LEVELS[0]);
|
||||
};
|
||||
const paintPace = (): void => {
|
||||
const p = PACE_OVERRIDE ?? settings.pace;
|
||||
paceLabel.textContent = p === 0 ? 'off' : `${p}×`;
|
||||
const i = PACE_LEVELS.indexOf(nearestPace() as (typeof PACE_LEVELS)[number]);
|
||||
paceSlowerBtn.disabled = i >= PACE_LEVELS.length - 1;
|
||||
paceFasterBtn.disabled = i <= 0;
|
||||
// A `?pace=` in the URL wins over the setting, so say so rather than showing dead buttons.
|
||||
if (PACE_OVERRIDE !== null) {
|
||||
paceSlowerBtn.disabled = true;
|
||||
paceFasterBtn.disabled = true;
|
||||
paceLabel.textContent = `${PACE_OVERRIDE}× (URL)`;
|
||||
}
|
||||
};
|
||||
const stepPace = (by: number): void => {
|
||||
const i = PACE_LEVELS.indexOf(nearestPace() as (typeof PACE_LEVELS)[number]);
|
||||
const next = PACE_LEVELS[Math.min(PACE_LEVELS.length - 1, Math.max(0, i + by))];
|
||||
if (next === undefined) return;
|
||||
saveSettings({ pace: next });
|
||||
paintPace();
|
||||
// The row's countdown is measured in steps that will dwell, so a change to 0 empties it at once.
|
||||
renderWatching();
|
||||
};
|
||||
// Slower is a BIGGER multiplier, so "−" walks up the ladder. Labelled by what it does to the game,
|
||||
// not to the number: a player pressing "slower" wants to watch for longer.
|
||||
paceSlowerBtn.onclick = () => stepPace(1);
|
||||
paceFasterBtn.onclick = () => stepPace(-1);
|
||||
paintPace();
|
||||
}
|
||||
|
||||
const zoomOutBtn = document.getElementById('zoomout') as HTMLButtonElement | null;
|
||||
const zoomInBtn = document.getElementById('zoomin') as HTMLButtonElement | null;
|
||||
const zoomLabel = document.getElementById('zoomlabel');
|
||||
|
||||
+84
-8
@@ -52,25 +52,61 @@ export function handHtml(f: Frame, canPlay: (boolean | null)[] = []): string {
|
||||
* Only the top card may ever be drawn, so the depth is a count and not a hint: everything below it
|
||||
* is out of reach, and choosing where to discard is choosing what to put there.
|
||||
*/
|
||||
export function pilesHtml(f: Frame): string {
|
||||
const pile = (label: string, top: string, depth: number, why: string, extra = '', slot = -1): string => {
|
||||
export function pilesHtml(f: Frame, lit: readonly string[] = []): string {
|
||||
const pile = (
|
||||
key: string,
|
||||
label: string,
|
||||
top: string,
|
||||
depth: number,
|
||||
why: string,
|
||||
extra = '',
|
||||
slot = -1,
|
||||
faceDown = false,
|
||||
): string => {
|
||||
const tip = [why, extra].filter(Boolean).join(' · ');
|
||||
// A Department is a DROP TARGET for a discard. The attribute is always emitted; only the play
|
||||
// page binds a click to it, and only while a card is waiting to be discarded — so the replay
|
||||
// viewer draws exactly the same markup and nothing there is clickable.
|
||||
const target = slot >= 0 ? ` data-dept="${slot}"` : '';
|
||||
// `lit` marks the pile the move being watched just touched — see `changedPiles`.
|
||||
const cls = `handcard${faceDown ? ' facedown' : ''}${lit.includes(key) ? ' pilelit' : ''}`;
|
||||
return (
|
||||
`<div class="handcard"${target}${tip ? ` data-tip="${esc(tip)}"` : ''} tabindex="0">` +
|
||||
`<div class="${cls}"${target}${tip ? ` data-tip="${esc(tip)}"` : ''} tabindex="0">` +
|
||||
`<div class="pilehd"><span>${esc(label)}</span><span class="depth">${depth}</span></div>` +
|
||||
`<b>${esc(top)}</b></div>`
|
||||
);
|
||||
};
|
||||
return (
|
||||
/**
|
||||
* THE HOME OFFICE DECK, which the screen had never drawn.
|
||||
*
|
||||
* `f.deck` has carried the face-down count since the Frame existed and nothing read it — the
|
||||
* exact shape of display gap `test/display-gaps.test.ts` was written to sweep for, surviving in
|
||||
* the panel that draws every OTHER pile. Asked for by Jesse 2026-09-10 for a second reason: a
|
||||
* player drawing from it is the commonest move nobody can see, so it needs somewhere to flash.
|
||||
*
|
||||
* FIRST, because that is the order a card travels: out of here, into a hand, then onto a
|
||||
* Department or the Salvage Yard. Face down, so the card slot says so rather than naming a card
|
||||
* — the whole point of this pile is that nobody knows what is on top.
|
||||
*/
|
||||
pile(
|
||||
'home',
|
||||
'Home Office',
|
||||
'face down',
|
||||
f.deck,
|
||||
'The draw deck. Face down — nobody sees what is on top, and a card drawn from here is private ' +
|
||||
'to whoever drew it. When it runs out, the Salvage Yard and the Departments are swept back ' +
|
||||
'into it.',
|
||||
'',
|
||||
-1,
|
||||
true,
|
||||
) +
|
||||
f.departments
|
||||
.map((d, i) => {
|
||||
const depth = f.departmentDepth[i] ?? 0;
|
||||
const under = depth - 1;
|
||||
return pile(
|
||||
`dept${i}`,
|
||||
`Dept ${i + 1}`,
|
||||
d,
|
||||
depth,
|
||||
@@ -81,6 +117,7 @@ export function pilesHtml(f: Frame): string {
|
||||
})
|
||||
.join('') +
|
||||
pile(
|
||||
'salvage',
|
||||
'Salvage',
|
||||
f.salvage.top,
|
||||
f.salvage.depth,
|
||||
@@ -180,7 +217,7 @@ export function dayEndHtml(f: Frame): string {
|
||||
ahead +
|
||||
standingsHtml(f) +
|
||||
targetHtml(f) +
|
||||
collisionsHtml(f)
|
||||
collisionsHtml(f, ended)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -238,13 +275,28 @@ function targetHtml(f: Frame): string {
|
||||
* its config and enforces neither, so reporting a collision budget there would put a rule on
|
||||
* screen that this game does not have.
|
||||
*/
|
||||
function collisionsHtml(f: Frame): string {
|
||||
function collisionsHtml(f: Frame, endedDay?: number): string {
|
||||
const scoredOnCollisions =
|
||||
(f.mode === 'competitive' || f.mode === 'coop') &&
|
||||
(f.maxCollisionsTotal > 0 || f.maxCollisionsPerDay > 0);
|
||||
return scoredOnCollisions
|
||||
? `<p>Collisions: <b>${f.collisionsToday}</b> today, <b>${f.collisionsTotal}</b> in all.</p>`
|
||||
: '';
|
||||
if (!scoredOnCollisions) return '';
|
||||
/**
|
||||
* "TODAY" IS THE WRONG WORD IN A DAY-END DIALOG, and it read as a contradiction.
|
||||
*
|
||||
* That dialog is drawn from the frame whose `day` went UP — which is the same frame in which
|
||||
* `collisionsToday` was reset — so it reported 0 however many there had been. Jesse, 2026-09-09,
|
||||
* at the end of a Day 1 with two collisions in it: "it shows a total of two collisions, but zero
|
||||
* today ... that does seem to be a contradiction."
|
||||
*
|
||||
* So when the caller knows which Day just ended it says so by name, and reads the count captured at
|
||||
* the rollover. The end-of-game results screen passes nothing and keeps "today", where the Day has
|
||||
* not turned over and the word is accurate.
|
||||
*/
|
||||
const [count, when] =
|
||||
endedDay === undefined
|
||||
? [f.collisionsToday, 'today']
|
||||
: [f.collisionsPrevDay, `on Day ${endedDay}`];
|
||||
return `<p>Collisions: <b>${count}</b> ${when}, <b>${f.collisionsTotal}</b> in all.</p>`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -661,6 +713,30 @@ h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:#8b94a3;ma
|
||||
.handcard:focus{outline:2px solid #4d6fa8;outline-offset:1px}
|
||||
.cardrow.ref .handcard{background:#1c2129;border-style:dashed;border-color:#39424e;color:#b6bec9}
|
||||
.handcard.unplayable{color:#7d8794;border-color:#39424e}
|
||||
/* THE HOME OFFICE DECK. Face down, so its card slot names no card — it says so instead, in the
|
||||
dimmed voice the rest of the panel uses for "nothing to read here". */
|
||||
.handcard.facedown > b{color:#6f7885;font-style:italic;font-weight:400}
|
||||
/* THE PILE A WATCHED MOVE JUST TOUCHED (v0.8.1).
|
||||
A STATE, NOT A FLASH, and that is the whole point. The .tt-slot.fresh rule above animates for a fixed
|
||||
1.5s, which is right for a die roll nobody is waiting on — but a step can hold the screen for
|
||||
seven seconds at 10x, so a fixed animation would be over long before the pause it belongs to and
|
||||
the player would be back to staring at an unchanged board. The flash-in marks the moment; the lit
|
||||
border and background stay for exactly as long as the step is up, because the class is on the
|
||||
element only while that step is the one being shown. */
|
||||
.handcard.pilelit{border-color:#8fd6a0;background:#1d3327;box-shadow:0 0 0 2px #2f6b47,0 0 14px rgba(143,214,160,.55);
|
||||
animation:pilepulse 1.15s ease-in-out infinite}
|
||||
/* A PULSE FOR THE WHOLE DWELL, not one flash at the start. Measured: at 10x a pile stays lit for
|
||||
just under seven seconds, so the highlight was never brief — but a single 0.45s flash-in and a
|
||||
dark green fill were easy to miss entirely while watching the district. Jesse: "caught one flash
|
||||
deck light up for just a very brief moment, but couldn't see that with what bot was doing in
|
||||
office area and history and catch up area all at same time." Something still moving keeps drawing
|
||||
the eye for as long as the move is up; a state that settles stops asking to be looked at. */
|
||||
@keyframes pilepulse{0%,100%{background:#1d3327;box-shadow:0 0 0 2px #2f6b47,0 0 14px rgba(143,214,160,.45)}
|
||||
50%{background:#2f6b47;box-shadow:0 0 0 3px #8fd6a0,0 0 22px rgba(143,214,160,.85)}}
|
||||
/* Motion is the point here, so the reduced-motion fallback has to be loud in a different way rather
|
||||
than simply not moving: a solid ring and a brighter fill, held. */
|
||||
@media(prefers-reduced-motion:reduce){
|
||||
.handcard.pilelit{animation:none;background:#2f6b47;box-shadow:0 0 0 3px #8fd6a0}}
|
||||
.handcard.unplayable::after{content:"";position:absolute;inset:0;border-radius:5px;pointer-events:none;
|
||||
background:repeating-linear-gradient(45deg,transparent 0 5px,rgba(150,160,175,.20) 5px 6px)}
|
||||
/* THE CARD JUST DRAWN. It sits first in the row, and this says which one that is — three cards that
|
||||
|
||||
+13
-1
@@ -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;
|
||||
background:#22263a;border:1px solid #343a52;border-radius:10px;padding:1px 8px;white-space:nowrap}
|
||||
#watching-skip{font-size:11px;padding:2px 10px;border-color:#e0b060;color:#e0b060;flex:none}
|
||||
#watching-who{color:#c9cee0;font-weight:700}
|
||||
#presence:empty{display:none}
|
||||
/* division strip */
|
||||
#division{display:flex;gap:7px;overflow-x:auto;padding-bottom:4px}
|
||||
@@ -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.">
|
||||
<button id="zoomout" aria-label="Zoom out">−</button><span id="zoomlabel">100%</span><button id="zoomin" aria-label="Zoom in">+</button>
|
||||
</span>
|
||||
<!-- HOW FAST OTHER PLAYERS' TURNS PLAY BACK — v0.8.0.3, TODO #13.
|
||||
A CONTROL RATHER THAN ONLY A URL PARAMETER. `?pace=` shipped first and is unreachable through
|
||||
the front door: `index.html`'s two doors are `play.html?lobby` and `play.html?solitaire`, so
|
||||
arriving from the splash REPLACES the query string and any pace with it. Jesse played a whole
|
||||
game believing he was at 7x when he was at 1x. -->
|
||||
<span class="zoom" title="How long another player's or a bot's move is held on screen before the next one. Yours are never delayed. Off draws every move at once, as it did before v0.8.0.">
|
||||
<button id="paceslower" aria-label="Slower playback">−</button><span id="pacelabel">1×</span><button id="pacefaster" aria-label="Faster playback">+</button>
|
||||
</span>
|
||||
<button id="undo" title="Take the last action back. The save is the seed plus the moves made, so this replays the game without the last one — as far back as you like.">Undo</button>
|
||||
<button id="savefile" title="Download this game as a save file you can replay or share">Save replay</button>
|
||||
<button id="newgame" title="Set up a fresh game — the seed, the table, the opening hand and what the three economies pay. Opens the same screen a new solitaire game starts from, with your current rules filled in; your game in progress is kept until you press Deal, and Continue puts it straight back.">New game</button>
|
||||
@@ -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
|
||||
almost always. -->
|
||||
<div id="watching" hidden>
|
||||
<!-- SKIP FIRST, on the left. It sat on the far right and a player's eye is on the countdown, not at
|
||||
the other end of the row — Jesse, 2026-09-09: "the skip button should be on the far left, in
|
||||
front of where it says [the count], so it's always close to where people are looking." -->
|
||||
<button id="watching-skip" class="ghost" type="button" title="Stop animating and jump the board to where the game actually is. Nothing is lost — every line is already in the History panel.">Skip</button>
|
||||
<span id="watching-behind" class="wbehind"></span>
|
||||
<span id="watching-what"></span>
|
||||
<button id="watching-skip" class="ghost" type="button" title="Stop animating and jump the board to where the game actually is. Nothing is lost — every line is already in the History panel.">Skip</button>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
|
||||
@@ -40,6 +40,29 @@ if (heroImage && lightbox) {
|
||||
* other way costs a click and a lobby that says it cannot reach a server — which is legible, and
|
||||
* recoverable. So a slow or flaky probe leaves the door alone; only a definite answer closes it.
|
||||
*/
|
||||
/**
|
||||
* CARRY `?pace=` THROUGH THE DOORS — v0.8.0.3.
|
||||
*
|
||||
* Both doors are static hrefs that REPLACE the query string (`play.html?lobby`,
|
||||
* `play.html?solitaire`), so a `pace` typed on this page was silently dropped on the way in: Jesse
|
||||
* played a whole game believing he was at 7× when the play page had only ever seen `?lobby`. The
|
||||
* durable answer is the speed control on the play screen, which persists per viewer — this keeps the
|
||||
* URL lever honest for handing two playtesters different speeds, which is the only thing it was ever
|
||||
* for.
|
||||
*/
|
||||
try {
|
||||
const pace = new URLSearchParams(location.search).get('pace');
|
||||
if (pace !== null) {
|
||||
for (const door of Array.from(document.querySelectorAll('a.door'))) {
|
||||
const href = door.getAttribute('href');
|
||||
// Only the doors into the game, and only ones that have not been disabled above.
|
||||
if (href?.startsWith('./play.html?')) door.setAttribute('href', `${href}&pace=${encodeURIComponent(pace)}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// A door that keeps its own href is the status quo, not a broken page.
|
||||
}
|
||||
|
||||
const mpDoor = document.getElementById('door-multiplayer');
|
||||
if (mpDoor) {
|
||||
const close = (): void => {
|
||||
|
||||
+19
-1
@@ -19,7 +19,8 @@
|
||||
|
||||
import type { PublicFrame } from '../sim/view.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';
|
||||
|
||||
export type StepQueue = {
|
||||
@@ -43,6 +44,13 @@ export type StepQueue = {
|
||||
behind(): number;
|
||||
/** The last step actually shown, for the caption line (#15). Null before anything has been shown. */
|
||||
showing(): DisplayStep | null;
|
||||
/**
|
||||
* The piles the step now on screen moved, for the display to light.
|
||||
*
|
||||
* Here because this is the only place that holds both the frame before a step and the frame after
|
||||
* it — deriving it anywhere else would mean keeping a second copy of the board in step.
|
||||
*/
|
||||
lit(): readonly PileKey[];
|
||||
/** True while there is anything left to show. */
|
||||
busy(): boolean;
|
||||
};
|
||||
@@ -65,6 +73,7 @@ export function createStepQueue(
|
||||
): StepQueue {
|
||||
let shown: PublicFrame | null = null;
|
||||
let last: DisplayStep | null = null;
|
||||
let litPiles: readonly PileKey[] = [];
|
||||
let pending: DisplayStep[] = [];
|
||||
/** When the step now on screen is due to give way. Null when nothing is waiting. */
|
||||
let dueAt: number | null = null;
|
||||
@@ -75,8 +84,14 @@ export function createStepQueue(
|
||||
|
||||
/** Applies one step to the displayed board. A step's delta chains off the previous step's frame. */
|
||||
const show = (step: DisplayStep): void => {
|
||||
const before = shown;
|
||||
shown = applyPublicDelta(shown, step.frame);
|
||||
last = step;
|
||||
/**
|
||||
* NOT FOR YOUR OWN MOVES. You drew that card; you do not need the deck flashed at you. Same rule
|
||||
* that gives your own steps no dwell — the display is for watching everybody else.
|
||||
*/
|
||||
litPiles = step.player !== null && step.player === viewer() ? [] : changedPiles(before, shown);
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -84,6 +99,8 @@ export function createStepQueue(
|
||||
shown = frame;
|
||||
pending = [];
|
||||
dueAt = null;
|
||||
// Nothing was watched arriving at this board, so nothing on it is lit.
|
||||
litPiles = [];
|
||||
// `last` deliberately survives: a reconnect should not blank the caption line, and the
|
||||
// sentence describing the most recent action is still true.
|
||||
},
|
||||
@@ -136,6 +153,7 @@ export function createStepQueue(
|
||||
current: () => shown,
|
||||
behind: () => pending.filter((s) => dwell(s) > 0).length,
|
||||
showing: () => last,
|
||||
lit: () => litPiles,
|
||||
/**
|
||||
* STILL SHOWING SOMETHING, not just still holding something back.
|
||||
*
|
||||
|
||||
+73
-5
@@ -14,7 +14,7 @@ import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { DWELL, MAX_PACE, 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 { Intent } from '../src/engine/intents.ts';
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('pacing — dwell by kind', () => {
|
||||
assert.equal(kindOf('switch.end'), 'bookkeeping');
|
||||
/**
|
||||
* `localOps.choose` IS AN ANNOUNCEMENT, not bookkeeping — moved 2026-09-09 after the first real
|
||||
* play on `phoenix.local`. It is the line reading "Player Bot 1 chose to SWITCH", the heading for
|
||||
* 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.
|
||||
*/
|
||||
@@ -120,6 +120,37 @@ describe('pacing — dwell by kind', () => {
|
||||
assert.equal(watchableCount(queue, 0), 0, 'with animation off, nothing is behind');
|
||||
});
|
||||
|
||||
it('offers speeds a player actually reached for, and none the code would clamp', () => {
|
||||
/**
|
||||
* Jesse played a whole game believing he was at 7× and was in fact at 1×: `?pace=` shipped as the
|
||||
* only lever, and `index.html`'s doors are `play.html?lobby` / `play.html?solitaire`, so arriving
|
||||
* from the splash REPLACES the query string. Hence a real control on the play screen, and hence
|
||||
* this ladder — which must reach the speeds people ask for and must not offer one that
|
||||
* `dwellFor` would silently clamp.
|
||||
*/
|
||||
assert.equal(PACE_LEVELS[0], 0, 'off must be the first rung — #18 wants it turned off');
|
||||
assert.ok(PACE_LEVELS.includes(1), 'the default must be on the ladder');
|
||||
assert.ok(PACE_LEVELS.includes(7), '7x was asked for by name');
|
||||
/**
|
||||
* The ceiling is not theoretical. Jesse played at 10× — the top of the ladder as it then was —
|
||||
* and called it "still a bit fast, but followable", so the ladder has to go past the speed
|
||||
* somebody actually reached for and found insufficient.
|
||||
*/
|
||||
assert.ok(PACE_LEVELS.some((p) => p > 10), 'the ladder must go beyond the speed that was too fast');
|
||||
for (const p of PACE_LEVELS) {
|
||||
assert.ok(p <= MAX_PACE, `${p}x is past MAX_PACE, so the control would lie about it`);
|
||||
assert.equal(dwellFor('switch.move', p), Math.round(DWELL.switching * p));
|
||||
}
|
||||
// Strictly increasing, so stepping the control always changes the speed.
|
||||
for (let i = 1; i < PACE_LEVELS.length; i++) {
|
||||
assert.ok(PACE_LEVELS[i]! > PACE_LEVELS[i - 1]!, 'the ladder must be strictly increasing');
|
||||
}
|
||||
// The slowest rung has to be slow enough to be worth having: six switching moves at the top of
|
||||
// the ladder is a full minute, which is the "watch them struggle" case.
|
||||
const slowest = dwellFor('switch.move', PACE_LEVELS[PACE_LEVELS.length - 1]!) * 6;
|
||||
assert.ok(slowest >= 60_000, `the slowest a switching turn can be watched is ${slowest}ms`);
|
||||
});
|
||||
|
||||
it('a silent step beats only when the clock turns over — TODO #18', () => {
|
||||
/**
|
||||
* Both obvious rules were wrong, so both are pinned. "No narration, no dwell" flashed past
|
||||
@@ -127,18 +158,55 @@ describe('pacing — dwell by kind', () => {
|
||||
* that changed the board" beat on every turn hand-off — `submit()` steps `advance()` about 4.6
|
||||
* times per intent — which came to a quarter of an hour a game.
|
||||
*/
|
||||
const silent = { cause: 'phase' as const, lines: [] as string[] };
|
||||
const silent = { cause: 'phase' as const, player: null, lines: [] as string[] };
|
||||
assert.equal(dwellForStep({ ...silent, frame: { table: { actor: 2 } } }), 0, 'a turn hand-off shows nothing');
|
||||
assert.equal(dwellForStep({ ...silent, frame: { table: {} } }), 0, 'a step that changed nothing shows nothing');
|
||||
assert.equal(dwellForStep({ ...silent, frame: { table: { phase: 'mainline' } } }), DWELL.phase);
|
||||
assert.equal(dwellForStep({ ...silent, frame: { table: { stage: 4 } } }), DWELL.phase);
|
||||
// Narration always earns the dwell of whatever caused it, clock or no clock.
|
||||
assert.equal(
|
||||
dwellForStep({ cause: 'switch.move', lines: ['moved'], frame: { table: {} } }),
|
||||
dwellForStep({ cause: 'switch.move', player: 1, lines: ['moved'], frame: { table: {} } }),
|
||||
DWELL.switching,
|
||||
);
|
||||
});
|
||||
|
||||
it('the speed control stretches the clock at a THIRD of the rate it stretches people', () => {
|
||||
/**
|
||||
* Two complaints, one from each direction, and the answer is between them.
|
||||
*
|
||||
* v0.8.0.3, from a 5× game: *"after my turn … I'm still subject to that same delay before it
|
||||
* moves on. That makes no sense."* — phases were scaling with everything else and walling off a
|
||||
* player's own turn. So they were pinned at their tabled beat.
|
||||
*
|
||||
* v0.8.0.7, from a 10× game: *"phases displayed on the upper line go by too quickly still.
|
||||
* Should be 4 times as long — at a guess."* — pinned was too short to read the caption.
|
||||
*
|
||||
* Damped scaling satisfies both: 1× unchanged, 10× lands exactly on the four-times guess, and
|
||||
* the cost stays bounded because phase beats cluster rather than accumulate.
|
||||
*/
|
||||
const phase = { cause: 'phase' as const, player: null, lines: ['New Train'], frame: { table: { phase: 'newTrain' } } };
|
||||
const theirs = { cause: 'switch.move' as const, player: 1, lines: ['moved'], frame: { table: {} } };
|
||||
|
||||
assert.equal(dwellForStep(phase, 1), DWELL.phase, '1x must be exactly the tabled beat');
|
||||
assert.equal(dwellForStep(phase, 10), DWELL.phase * 4, '10x must be four times it, as asked for');
|
||||
|
||||
for (const pace of [2, 3, 5, 7, 10, 15, 20]) {
|
||||
const p = dwellForStep(phase, pace);
|
||||
const t = dwellForStep(theirs, pace);
|
||||
assert.ok(p > DWELL.phase, `a phase must grow at ${pace}x`);
|
||||
assert.ok(
|
||||
p < DWELL.phase * pace,
|
||||
`a phase must grow SLOWER than the multiplier at ${pace}x, or the clock walls off the turn`,
|
||||
);
|
||||
assert.ok(t > p, `somebody's move must still outlast a phase beat at ${pace}x`);
|
||||
}
|
||||
// Off still means off, for the clock as much as for anybody's move; and below 1x the clock
|
||||
// follows the multiplier straight, because "faster" should mean everything.
|
||||
assert.equal(dwellForStep(phase, 0), 0);
|
||||
assert.equal(dwellForStep(theirs, 0), 0);
|
||||
assert.equal(dwellForStep(phase, 0.5), DWELL.phase * 0.5);
|
||||
});
|
||||
|
||||
it('a real switching turn is watchable in a few seconds, not tens of them', () => {
|
||||
// Six moves is the engine's cap per crew ("N of 6 Moves left"), so this is the worst ordinary
|
||||
// case for one crew: the announcement, six moves, and an end that shows nothing.
|
||||
@@ -155,7 +223,7 @@ describe('pacing — dwell by kind', () => {
|
||||
|
||||
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 `phoenix.local`:
|
||||
* 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
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.ts'
|
||||
import { applyIntent } from '../src/engine/apply.ts';
|
||||
import { currentActorOfState, publicSnapshot } from '../src/sim/view.ts';
|
||||
import type { PublicFrame } from '../src/sim/view.ts';
|
||||
import { applyPublicDelta, deltaPublicFrame } from '../src/sim/public-delta.ts';
|
||||
import { applyPublicDelta, changedPiles, deltaPublicFrame } from '../src/sim/public-delta.ts';
|
||||
|
||||
const config: GameConfig = {
|
||||
mode: 'competitive',
|
||||
@@ -184,3 +184,73 @@ describe('public frame delta', () => {
|
||||
});
|
||||
|
||||
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)), []);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -524,6 +524,9 @@ describe('#91 — nothing private survives serialisation, in any state', () => {
|
||||
// The rules the game was dealt under, and the score.
|
||||
'houseRules', 'mode', 'optionalRules', 'days', 'minCombinedRevenue',
|
||||
'maxCollisionsPerDay', 'maxCollisionsTotal', 'collisionsToday', 'collisionsTotal',
|
||||
// What the Day that just ended finished on. Public for the same reason the running counts are:
|
||||
// a collision happens on the Mainline in front of everybody.
|
||||
'collisionsPrevDay',
|
||||
'status', 'outcome', 'extraDays', 'extensionVotes', 'official', 'tally',
|
||||
// Names, seats, revenue and HAND SIZE — never hand contents.
|
||||
'players',
|
||||
|
||||
+20
-1
@@ -179,7 +179,7 @@ describe('the step queue', () => {
|
||||
* 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 `phoenix.local`: *"I briefly saw that it was the bot's office area then their turn was
|
||||
* 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
|
||||
@@ -251,6 +251,25 @@ describe('the step queue', () => {
|
||||
assert.ok(q.showing() !== null, 'the caption should survive a reset');
|
||||
});
|
||||
|
||||
it('can always be emptied, so a player is never stranded behind it', () => {
|
||||
/**
|
||||
* "Your Move" is put away while the board is catching up (v0.8.0.6), which makes `busy()` the
|
||||
* thing standing between a player and their own turn. So the ways it can be cleared matter more
|
||||
* than they did: `skip()` must always work, from any state, including one where the clock has
|
||||
* never advanced — which is exactly the situation a page with no `requestAnimationFrame` is in,
|
||||
* and how this was found.
|
||||
*/
|
||||
const { steps, final } = realSteps(1917398, 400);
|
||||
const q = createStepQueue();
|
||||
q.reset(baseline(1917398));
|
||||
q.push(steps);
|
||||
// Never advanced at all: no frame has been shown, and the queue is full.
|
||||
assert.equal(q.busy(), true);
|
||||
assert.equal(q.skip(), true, 'a never-advanced queue must still be skippable');
|
||||
assert.equal(q.busy(), false, 'and must be idle afterwards, or the player stays locked out');
|
||||
assert.deepEqual(q.current(), final);
|
||||
});
|
||||
|
||||
it('draws nothing before a reset has arrived', () => {
|
||||
const q = createStepQueue();
|
||||
assert.equal(q.current(), null);
|
||||
|
||||
+131
-1
@@ -23,7 +23,7 @@ import { variantsFor } from '../src/engine/track.ts';
|
||||
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
|
||||
import type { DivisionView } from '../src/sim/view.ts';
|
||||
import { ENHANCEMENT_RULES, STAGES_PER_DAY } from '../src/engine/content.ts';
|
||||
import { dayEndHtml, facilitiesHtml, resultsHtml, timetableHtml } from '../src/web/panels.ts';
|
||||
import { dayEndHtml, facilitiesHtml, pilesHtml, resultsHtml, timetableHtml } from '../src/web/panels.ts';
|
||||
import { turnChartHtml } from '../src/sim/turnchart.ts';
|
||||
import { fieldSelectors } from '../src/web/settings-form.ts';
|
||||
import { record, renderHtml } from '../src/sim/replay.ts';
|
||||
@@ -3614,6 +3614,136 @@ describe('the Day rolling over says so (Gitea#10)', () => {
|
||||
assert.ok(html.includes('3 Days left'), `the Days remaining are wrong:\n${html}`);
|
||||
});
|
||||
|
||||
it('draws the Home Office deck, face down, with its count', () => {
|
||||
/**
|
||||
* `f.deck` has carried the face-down count since the Frame existed and NOTHING drew it — the
|
||||
* exact display gap `test/display-gaps.test.ts` sweeps for, surviving in the panel that draws
|
||||
* every other pile. Asked for by Jesse 2026-09-10, who also wanted somewhere for a draw to
|
||||
* flash: taking a card off this deck is the commonest move nobody can see.
|
||||
*/
|
||||
const s = createEngineGame({
|
||||
id: 'piles',
|
||||
seed: 5,
|
||||
config: {
|
||||
mode: 'competitive', days: 5, minCombinedRevenue: 60,
|
||||
maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
},
|
||||
playerNames: ['Joe', 'Bot 1'],
|
||||
});
|
||||
const f = snapshot(s, [], null);
|
||||
assert.ok(f.deck > 0, 'the deal should leave cards in the Home Office deck');
|
||||
|
||||
const html = pilesHtml(f);
|
||||
assert.ok(html.includes('Home Office'), `no Home Office pile:\n${html}`);
|
||||
assert.ok(html.includes(`>${f.deck}<`), 'the face-down count is not shown');
|
||||
// Face down means the card slot must NOT name a card — that is the whole point of the pile.
|
||||
assert.ok(html.includes('facedown'), 'the Home Office pile is not marked face down');
|
||||
assert.ok(html.includes('face down'), 'the card slot should say so rather than naming a card');
|
||||
// It comes first: a card travels out of here, then onto a Department or the Salvage Yard.
|
||||
assert.ok(
|
||||
html.indexOf('Home Office') < html.indexOf('Dept 1'),
|
||||
'the draw deck should be read before the piles cards land on',
|
||||
);
|
||||
});
|
||||
|
||||
it('names the train on top of the Salvage Yard, instead of "a card"', () => {
|
||||
/**
|
||||
* The Salvage Yard is a FACE-UP pile and its tile reads the top card — but `trainScheduled`
|
||||
* pushes a synthetic `train-<number>` id rather than the id of the card that was played
|
||||
* (`apply.ts`), so there was nothing in `s.cards` to look up and the tile said "a card". A train
|
||||
* is scheduled several times a Day, so that id is on top most of the time: the pile was face up
|
||||
* and had nothing to say. Jesse, 2026-09-10: *"why is salvage deck not face up. I should see the
|
||||
* card played onto salvage."*
|
||||
*/
|
||||
const s = createEngineGame({
|
||||
id: 'salv',
|
||||
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'],
|
||||
});
|
||||
s.decks.salvageYard.push('train-13');
|
||||
const f = snapshot(s, [], null);
|
||||
assert.equal(f.salvage.top, 'Train 13', 'a scheduled train on the pile must be named');
|
||||
|
||||
const html = pilesHtml(f);
|
||||
assert.ok(html.includes('Train 13'), `the Salvage tile does not name the train:\n${html}`);
|
||||
assert.equal(html.includes('>a card<'), false, 'the face-up pile still says "a card"');
|
||||
});
|
||||
|
||||
it('lights only the pile a watched move touched', () => {
|
||||
const s = createEngineGame({
|
||||
id: 'piles2',
|
||||
seed: 5,
|
||||
config: {
|
||||
mode: 'competitive', days: 5, minCombinedRevenue: 60,
|
||||
maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
},
|
||||
playerNames: ['Joe', 'Bot 1'],
|
||||
});
|
||||
const f = snapshot(s, [], null);
|
||||
|
||||
assert.equal(pilesHtml(f).includes('pilelit'), false, 'nothing is lit when nothing was watched');
|
||||
|
||||
const home = pilesHtml(f, ['home']);
|
||||
assert.equal((home.match(/pilelit/g) ?? []).length, 1, 'exactly one pile should light');
|
||||
assert.ok(
|
||||
home.indexOf('pilelit') < home.indexOf('Dept 1'),
|
||||
'a Home Office draw must light the Home Office pile, not a Department',
|
||||
);
|
||||
|
||||
const dept2 = pilesHtml(f, ['dept1']);
|
||||
assert.equal((dept2.match(/pilelit/g) ?? []).length, 1);
|
||||
assert.ok(dept2.indexOf('Dept 2') > dept2.indexOf('Dept 1'), 'order sanity');
|
||||
// Two piles can move at once — a Department draw that refills from the deck.
|
||||
assert.equal((pilesHtml(f, ['home', 'dept0']).match(/pilelit/g) ?? []).length, 2);
|
||||
});
|
||||
|
||||
it('reports the ENDED Day\'s collisions, not the fresh Day\'s zero', () => {
|
||||
/**
|
||||
* Jesse, 2026-09-09, at the end of a Day 1 with two collisions in it: *"It shows a total of two
|
||||
* collisions, but zero today. Since we just finished day one, that does seem to be a
|
||||
* contradiction."*
|
||||
*
|
||||
* The cause is a one-line ordering fact: `advance.ts` increments the Day and then zeroes
|
||||
* `collisionsToday`, and this dialog is drawn from the frame whose Day went UP — so it read the
|
||||
* fresh Day's zero and printed it beside a running total that could not agree with it. The count
|
||||
* is captured at the rollover now, and the dialog names the Day rather than saying "today".
|
||||
*/
|
||||
const s = createEngineGame({
|
||||
id: 'collide',
|
||||
seed: 5,
|
||||
config: {
|
||||
mode: 'competitive',
|
||||
days: 5,
|
||||
minCombinedRevenue: 60,
|
||||
maxCollisionsPerDay: 3,
|
||||
maxCollisionsTotal: 10,
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
},
|
||||
playerNames: ['Joe', 'Bot 1'],
|
||||
});
|
||||
// The state as the rollover out of Day 1 leaves it: two collisions happened, `today` is reset.
|
||||
s.clock.day = 2;
|
||||
s.collisionsPrevDay = 2;
|
||||
s.collisionsToday = 0;
|
||||
s.collisionsTotal = 2;
|
||||
|
||||
const html = dayEndHtml(snapshot(s, [], null));
|
||||
assert.ok(html.includes('Day 1 has ended'), `wrong Day named:\n${html}`);
|
||||
assert.ok(html.includes('<b>2</b> on Day 1'), `the ended Day's collisions are wrong:\n${html}`);
|
||||
assert.ok(html.includes('<b>2</b> in all'), `the running total is wrong:\n${html}`);
|
||||
assert.doesNotMatch(html, /<b>0<\/b> today/, `still reporting the fresh Day's zero:\n${html}`);
|
||||
// The contradiction itself: a Day-end dialog must never claim fewer in all than on that Day.
|
||||
assert.doesNotMatch(html, /<b>0<\/b> on Day 1/, 'reported no collisions on a Day that had two');
|
||||
});
|
||||
|
||||
it('counts the last Day as the last Day rather than promising more', () => {
|
||||
const html = dayEndHtml(frameAt(6));
|
||||
assert.ok(html.includes('Day 5 has ended'), 'the final Day is misnamed');
|
||||
|
||||
Reference in New Issue
Block a user