Compare commits

..
3 Commits
Author SHA1 Message Date
Jesse.MarkowitzandClaude Opus 5 7c9ef8797d v0.8.0.12 — put a player back in their seat after losing their browser storage
Gitea#33. A session token is the only identity the game has, and it lives in
exactly one place the player controls: their browser's localStorage, scoped to
the origin they joined at. Lose it — a cleared profile, a private window, a
different browser — and the seat is unreachable while the game runs on and the
session sits intact on disk. Reported from the table: of two humans in one game
the host reloaded straight back in, the joiner met an empty lobby.

Diagnosed before it was fixed, and two server-side theories of mine were
retracted on the evidence: no storage key changed in 0.8.0.11, nothing in the
app deletes the secret or name, create and join both call persistSession, that
game's sessions.json held both seats, and it resumed with 80 intents replayed.
Both players used the same URL, so it was not a second origin either.

The fix is a recovery link. An administrator mints a code for a named seat
(admin-gated: deciding somebody lost a seat is a judgement no route can make);
the player opens the link and the page trades the code for the token over a
POST, then strips it from the address bar. The link never carries the token —
lobby-and-sessions.md §1 says keep it out of URLs, and a recovery link is
exactly what gets pasted into a chat. Single use, 30-minute expiry, held in
memory because a restart dropping them is the right failure.

server/claims.ts is a pure store, so single use, lazy expiry and one identical
answer for unknown/spent/expired codes are tested rather than asserted. The
admin game listing gained seatedPlayers — the seats a human holds a token for,
read from the session map rather than guessed from player names — so the
StartOS action can offer real players instead of bot chairs.

No rule changed: `git diff v0.8.0.11..v0.8.0.12 -- src/engine/` is empty, so
games in progress resume.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nnuCv8UodHucFfx3LWEoX
2026-09-16 20:16:24 -04:00
Jesse.MarkowitzandClaude Opus 5 9a9e50b3c6 v0.8.0.11 — sixteen fixes from the second multiplayer playtest
Arrivals name whose Office they reached, and no longer tell every seat they can
work the train. The turn chart follows the animation queue, so being five behind
looks five behind across the whole screen rather than half of it. Pause sits
beside Skip and preserves the dwell a held step still owed. A one-render look at
another player's Office Area. The district summary counts the board being shown.
LIMITS is printed beneath its card instead of through its border. The Mainline
region divider is visible. Only Hilly mentions FAST/SLOW, because it is the only
card that reads it. A passenger Modifier on a Whistle Post reports itself dormant
rather than claiming the facility "only receives". An automatic phase says what
the Division is doing instead of answering by negation. The version appears once
in the header rather than twice on every .s9pk. Save files carry the join code,
the Stage and the date.

The New Train phase, reviewed before being changed: the make-up panel now says
what the train STILL needs rather than only what its card calls for, explains
that a player adds one car before the round passes on, marks the train being
loaded on the Division map, and gives an addable car in the yard the same amber
every other clickable thing on the page wears.

Reasoning, measurements and the reports behind each are in CHANGELOG.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nnuCv8UodHucFfx3LWEoX
2026-09-16 16:06:01 -04:00
Jesse.MarkowitzandClaude Opus 5 4adf149ba5 v0.8.0.10 — playtest fixes: clearance rulings, the log, the map, and a save file
From the first two multiplayer playtests of v0.8.0.9, each traced before fixing.

The engine:

- A train on a card BEHIND the one departing no longer triggers a clearance
  ruling or an opposite-direction bar (#26). Reproduced from the exported
  save: X15 was held over X18 behind it, and X18 then collided into the full
  Whistle Post. Games in progress holding a ruling the engine no longer asks
  for will not resume (28 of 40 recorded four-seat games); shipped as is at
  Jesse's call.
- `mainlineModified` carries the card's previous kind, so the log can say
  what a Realignment converted (#27).

The screen:

- The turn chart and the Division map name the player whose move is on
  screen while bot turns replay, not the live actor (#25).
- The owning player's name is no longer outlined by the turn arrow's stroke,
  which made it unreadable (#24).
- A Mainline card flashes on the map when a Realignment changes it (#28).
- The history is held back with the board and revealed step by step, instead
  of arriving whole while the board is still catching up (#29).
- A ruling made by holding the office reads "Superintendent Player X" (#30),
  and no line names a player twice (#31).
- A seated player can download their own game as a save file: the play
  page's Save replay button, fed by GET /api/save?token=… (#32). The StartOS
  action cannot do this — an action result is text only.

Closes #24
Closes #25
Closes #26
Closes #27
Closes #28
Closes #29
Closes #30
Closes #31
Closes #32

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nnuCv8UodHucFfx3LWEoX
2026-09-15 22:44:20 -04:00
32 changed files with 1713 additions and 84 deletions
+306
View File
@@ -19,6 +19,312 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
--- ---
## 0.8.0.12 — 2026-09-16
A player who has lost their browser storage can be put back in their seat (Gitea#33). No rule
changed: `git diff v0.8.0.11..v0.8.0.12 -- src/engine/` is empty, so games in progress resume.
### The failure this fixes, and the four things it was not
Reported from the table after the 0.8.0.11 update: of two humans in one game, the host reloaded
straight back into it and the player who had JOINED found an empty lobby — no join secret, no display
name, no game code. Their seat was never lost. `sessions.json` for that game held both seats, and the
server logged it resuming with 80 intents replayed.
Four explanations were ruled out with evidence before any code was written, and two of them were
theories of mine that had to be retracted:
- **Not the update.** `git diff v0.8.0.10..v0.8.0.11 -- src/web/` contains no storage change at all;
both tags declare identical `SECRET_KEY` and `NAME_KEY`.
- **Not a create-vs-join asymmetry in the client.** `lb-secret` and `lb-name` sit above both doors in
`play.html`, so a joiner writes the same three keys a host does.
- **Not the server forgetting joiners.** Create calls `persistSession` and so does join; it writes
every session for the game. Two-seat session files plainly work.
- **Not a second origin.** Both players used the identical URL.
What is left is the thing §1 has always said: the token lives in one browser's `localStorage`, scoped
to the origin. A cleared profile, a private window or a different browser ends the seat while the game
runs on without it. Nothing in the client can detect that — origin isolation is the point — and
nothing in it can repair it either.
### A recovery link carries a code, never the token
`lobby-and-sessions.md` §1: *"Keep it out of URLs so it is not shoulder-surfed or pasted into a
chat."* A recovery link is precisely what gets pasted into a chat, so the URL carries a **single-use
code that expires in 30 minutes** and the page trades it for the real token over a POST, then strips
it from the address bar. A spent code is worth nothing; a token in a chat log is the seat for the rest
of the game.
```
POST /api/games/<id>/claim { player } → { code, expiresAt, … } admin secret
POST /api/claim { code } → { token, gameId, player, gameCode }
```
**Minting is administrative; spending is not.** Deciding that a particular person has lost a
particular seat is a judgement no route can make safely — anyone able to mint their own code could
take any chair at the table. Spending needs no secret because the player following the link is the one
person in the story who holds none: the code *is* the authorisation, unguessable and one-time, which
is the same shape as the token it returns.
`server/claims.ts` is a pure store — no clock, no sockets, no disk — so its rules are actually tested
rather than asserted: single use, lazy expiry, and one identical answer for unknown, spent and expired
codes so it cannot be probed. The codes are held in memory on purpose. They are minted on demand and
spent within minutes with the administrator present, so a restart dropping them is the right failure;
persisting them would put a credential-equivalent on disk to solve a problem measured in seconds.
### The administrator picks a seat, not a string
The admin game listing now reports `seatedPlayers` — the seats a HUMAN holds a token for, read from
the server's session map rather than guessed by matching "Bot 1" against a display name. That is what
lets the StartOS side offer real players to choose from instead of chairs no token was ever issued
for.
## 0.8.0.11 — 2026-09-16
Fourteen reports from the second multiplayer playtest of v0.8.0.10, the WHISTLE-6945 table. Eleven are
fixed here; the review of the New Train phase turned into four changes of its own; and two were
questions the engine had already answered.
### A train's arrival says WHOSE Office it reached
The line went to every seat reading "Train 3 ARRIVED at the Whistle Post … **You** can work it in Cargo
now". At a table of four that is one true sentence and one false one: every seat has an Office, so the
tier alone never said which district the train was standing in, and three of the four readers could not
touch it. `trainArrived` now carries `owner`, and the narration resolves the name — "ARRIVED at Tom's
Whistle Post … Tom can work it". The Mainline Phase has no actor, which is why nothing upstream could
name the seat for it. Saves replay history rather than storing events, so the new field strands nothing.
### Being five behind now looks five behind everywhere
*"When player Jesse is 5 behind, it should always look like he's 5 behind."* The board, the district
panel and the caption row have followed the animation queue since v0.8.0; the turn chart never did. So a
player watching three bots play out a Stage saw their cards moving under a chart that had already ticked
over to the next phase — the one part of the screen quietly insisting the game was elsewhere. The Day,
Stage, clock, phase and Fedora now come from the step on screen (`shownTable`), as does the district
panel's auto-open. Being behind is fine and the counter says so; being behind on *part* of the screen is
what made it unreadable.
### Pause, beside Skip
Skip was the only control the catching-up row had, and it is one-way and total: the way to look harder
at a move that had just gone past was to not be too slow about it. Pause is the opposite lever. The
property that matters is not that it stops — any flag gives you that — but that a hold **costs the step
nothing**: a move paused half-way through its dwell resumes with half a dwell left, rather than being
thrown away the moment you let go. Skip lifts a hold rather than leaving a Resume button that does
nothing.
### A one-render look at another player's Office Area
One button per opponent in the district header. Deliberately not a mode: the look survives exactly the
render its own click causes, so the panel is back to following whoever is acting the next time anything
redraws. Jesse's call, and the right one — a sticky pin has to answer what happens when the game moves
on beneath it, and both honest answers are bad (snap home silently, or leave a player studying a stale
board while the game waits on them). Pause already means "hold everything", so that is the lever for a
long look and this stays a glance. Player names reach the page through `textContent`, never markup.
### The Office Area summary counts the district on screen
The panel has drawn somebody else's board since v0.8.0 and the heading says whose — but the summary
line read the viewer's own frame every time. So a bot's turn showed "Bot 2's Office Area" over Bot 2's
cards, above a line counting *your* cards, facilities and trains.
### LIMITS is printed beneath the card, not through it
*"The text is split by the bottom border of the limits card."* It was: the bottom row's edge lands at
`height - 7` and the label's baseline was `height - 4`, so its 8px glyphs spanned `height - 12` to
`height - 4` and the border ran through the middle of the word. Raising it would have put it on the
card, over the rails, so a 14px band is added below the bottom row and the baseline moved into it.
### The Mainline region divider is visible
Drawn at `#4a5361`, 1.2px, on a `#161b21` card — faint enough not to be made out at all. It is the ruler
the train is measured against and should stay quieter than the rail, but a ruler you cannot read is not
restraint.
### Only Hilly talks about FAST/SLOW
Every other Mainline card ended its description with "the printed speed is scenery", which sent a player
hunting the card for a number that is not drawn on it. Exactly one card reads the rating — Hilly, the
only profile with `speedStarts` — and it already explains itself. Everywhere else now says nothing,
which is the honest answer when the rating does not apply.
### A passenger Modifier on a Whistle Post says it is dormant
A Restaurant appeared to do nothing. It does nothing: a Whistle Post is not a Passenger Facility, so it
allows neither direction and has zero capacity each way; `usableGrant` discards the capacity while the
porter is granted regardless, leaving a porter with nothing to carry. The Freight Agent then refuses to
stock the box twice over, so no coach can ever be there to board. Playing it there stays legal on
Jesse's call — the card is not wasted, it starts working on upgrade — and the panel now says so. It had
been claiming the facility "only receives", which is wrong in both directions at a Whistle Post.
### An automatic phase says what it is doing
"waiting on **nobody — the Division is running itself**" reached its answer by negation, on the one line
whose job is to say where the game is. The phase's name is already printed directly above it, so these
describe the work instead: "the Division is moving trains", "the Division is building this Stage's
trains". Only a person is waited on.
### The version appears once in the header
The no-git fallback for the cache-bust key led with the version, and the stamp already begins with
`v${version}` — so the header read "v0.8.0.10 · 0.8.0.10-mfq2p1 · …". It showed up on exactly the builds
that take that path, which is **every `.s9pk`**, because the Dockerfile copies the tree in without
`.git`. The uniqueness that fallback exists to provide comes from the timestamp, not the version, so the
two requirements never competed. Pinned in `test/web.test.ts` beside the constant-fallback assertion
that the same function already carries.
### Save files name the game, the Stage and the day
Every server game downloaded as `station-master-day1-stage5.json`, so two saves off different tables
collided in the downloads folder and neither said which table it came from. The join code is what a
player already says out loud to identify a game, so it leads: `whistle-6945.day1.stage5.2026.09.16.json`.
A solitaire game has no code and falls back to its seed.
### The New Train phase says what is left, and why your turn ended
Reviewed before changing anything, at Jesse's request — and the phase turned out to be better served
than it looked from a grep: the Division Yard chip IS the button, the panel already names the train
and what its card calls for, warns about assembly order on the trains where order can lock switching,
and carries the "no more cars" button that exists to prevent a real softlock. Four things were missing.
**Still needs.** The heading says what the card CALLS FOR and goes on saying it unchanged as cars go
on, so the one question a player actually has while clicking — what is left? — was the only thing on
screen that had to be worked out by eye, against a consist drawn in the other column. `consistNeeds`
counts by the same categories `acceptsCar` does, so it can never ask for a car the engine would then
refuse, and it reads null when the consist is complete.
**One car each.** §7's round — a single car, then the next player, from the Superintendent working
left — was written down only in the turn chart's New Train chip tooltip: hovered once, early on, and
never again. "Click a car" then reads as "build this train", and the turn ends with no explanation.
It now says so where the clicking happens.
**The train is marked on the map.** `TrainChip` gained `beingMadeUp`, set from the engine's own
`isBeingMadeUp`, and the chip is drawn in the action amber — so the panel on the right and the train
on the Division strip at the top left are visibly one subject rather than two.
**A clickable car looks clickable.** The addable highlight was a thin blue outline, reported as "just
a small bold and basically the same color as everything else". It now wears `#c8912f`, the border
colour the action buttons themselves use. The loaded/empty text colours are deliberately left alone:
green and blue-grey are what say which of the two numbers is which, and that is a different question
from whether you may click.
### Asked, and already answered by the engine
**Does the bot know how to use Interlocking?** It plays it deliberately — the bot ranks it above the
other enhancements precisely because it stops an Office collision — and a Running Track straight is the
card's printed placement, so the play seen at the table was correct. Nobody "uses" it: `advance.ts`
applies it automatically, turning an arrival at a full Office into a hold at the Limits. There is no
decision to get wrong, so no change was made.
**Can passengers load or unload at a Whistle Post?** No. Zero porters, zero outbound, zero inbound, and
not a Passenger Facility — it takes a Depot to do any passenger work at all.
---
## 0.8.0.10 — 2026-09-15
Three reports from the first multiplayer playtest of v0.8.0.9 — one player and three bots — each traced
to its cause before it was fixed.
### The Superintendent is no longer asked about a train BEHIND the one departing (Gitea#26)
*"Two westbound trains: X15 is further west on Mainline cards than X18. And I still get a Superintendent
must rule... held X15 and then collision happened. X18 destroyed, but X15 fine."*
**Reproduced by replaying the exported save.** At move 78, X15 was highballing west out of seat 3's
Office while X18, also westbound, was still crossing the card to its EAST — behind it. Every Office was a
Whistle Post, so the Subdivision ran the whole railroad, and `evaluateClearance` counted every train in it
without asking which side of the departing train it stood on. The ruling was meaningless; holding X15
kept the Whistle Post's only A/D track full, and X18 arrived into it and was destroyed. Unasked, X15 —
the lower number, so the first to move — would have left and freed the track.
§8.1 asks about a train the departing one would FOLLOW and one moving TOWARDS it, and both are ahead of
it. So a train on a card strictly behind the departing train's own card is no longer counted, in either
pass: a following train behind is no concern, and an oncoming one behind is moving away. A train on the
SAME card is still counted, exactly as before — which of two trains sharing a card is in front is
`entryConflict`'s region question. The other two rulings in the save (moves 37 and 530) were correct and
are unchanged.
A test had the fault written into it: its "train ahead" of an eastbound train was the first Mainline card
in the Division, which is west of the Office. It now stands on a card the train would follow, and three
new cases pin the rule: a same-direction train behind is not put to the Superintendent, an opposite-
direction train behind does not bar the departure, and a same-direction train ahead still is. The tally
test's seed moved from 42 to 44, because the collision seed 42 was chosen for was this bug.
### Games in progress — MANY WILL NOT RESUME
**This release changes when the engine asks for a ruling**, so a save holding a ruling it would no longer
ask for stops replaying at that move: `mainline.clearance` is refused with `NO_PENDING_DECISION`. The
server then refuses to resume that game and logs the move it stopped at, leaving the save untouched —
putting v0.8.0.9 back would resume it. **Measured:** 40 four-seat co-op games recorded by bots under
v0.8.0.9 and replayed under this release — 28 stop, usually 5–40% of the way in, most at a ruling on a
train behind (about ten rulings a game were being asked). The exported playtest game stops at move 78.
**Jesse's call (2026-09-15): no games in progress need keeping, so the fix ships as it is,** with no
per-game switch preserving the old rule.
### Six more from the same playtest (Gitea#27–#32)
**A Realignment says which card it converted (#27).** *"It stated Mainline card 3 converted to plains. It
should state that the mainline card 3 curves was converted to plains."* The event carried only what the
card became, so the line could not name what it had been; `mainlineModified` now carries `from` as well —
events are derived by replaying a save and never stored, so widening one strands nothing — and it reads
"Realignment: Mainline card 3, Curves, converted to Plains".
**And the map flashes it (#28).** *"Is it possible to flash the mainline card when it gets changed by
realignment?"* The one play that changes the Division itself was invisible on the map of it.
`changedDivisionCards` compares the two public boards the animation queue already holds — the same way a
pile is lit — so the pulse lands with the step that shows the change rather than when the intent arrived,
and it is shown to the player who made it too, unlike a lit pile. `prefers-reduced-motion` turns it off.
**The history stops running ahead of the board (#29).** *"Does history display immediately for all bot
turns... is it possible to stall history so it stays in sync with the number behind?"* A push carries its
narration and its steps together, so every line of a bot's turn was in the panel before the board had
drawn any of it. The queue now reports how many lines belong to steps not yet shown, and the panel holds
back exactly those, revealing each as its step goes up. Skip still shows everything at once.
**A ruling reads as the office's (#30).** *"If a player makes a move as a superintendent instead of as
themselves maybe it could say 'Superintendent Player Tom'."* The three moves made by holding the office
rather than in turn — §8.1's clearance, §11's Yard Office offer, §Q's Red Flag prompt — are prefixed that
way. `clearanceGiven` carries no player at all (the office made it, whoever holds it), so the acting seat
is what names it.
**And it names them once (#31).** *"It gives the player's name and then their player number together."*
`record()` prefixes the acting player's NAME for every event carrying `player`, and five narration lines
embedded `Player <index>` themselves — phase ended, actor changed, the Red Flag ruling, the extension vote
and the Yard Office ruling. The name is the log's job; the sentence is the narration's. A test now scans a
played game's whole log for a bare player index.
**A multiplayer game can be saved as a file (#32).** *"In the StartOS actions for the save game, I get a
string I can copy. Most of the time, I want to just save it as a JSON file."* Not possible in the action
itself: an action result member is text only — copyable, QR or masked, with nested groups — and the SDK
has no file member. So it is done where it can be: the play page's "Save replay" button was shown only in
solitaire, because a server-backed session has no local save to hand it. `GET /api/save?token=…` — the
same seat token `/api/stream` and `/api/intent` use, not the administrative secret — hands a seated player
their own game, and the button now appears in multiplayer.
### "Waiting on" follows the move on screen (Gitea#25)
*"Playing against 3 bots — waiting on always says me, even when it is someone else's turn."*
The server plays every bot move the moment a human's turn ends, so the LIVE game is almost always waiting
on the human — while the screen is still replaying those bots step by step. The turn chart and the
Division map's move marker read the live actor, and contradicted the playback row naming the bot actually
moving. `actorOnScreen` (`web/step-queue.ts`) answers with the player of the step on screen while the
board is catching up, and the live actor once it has; during playback the chart also leaves off a live
"asks … ruling" note that belongs to a position the screen has not reached. Five tests pin it.
### The name on the Division map is readable again (Gitea#24)
*"When it shows your player, the font is unreadable... the bold font makes it look fuzzy, and the letters
blur together."*
A name takes the class `bs-turn` while it is that player's move — and `.bs-turn` is also the Division
map's turn ARROW, which strokes its shape 2.4px grey with no fill. The name's own rule changed only the
fill, so every letter was outlined in grey. It showed for the viewing player almost constantly because of
Gitea#25: the map believed it was nearly always their move. `.bs-name` now sets `stroke:none`.
---
## 0.8.0.9 — 2026-09-15 ## 0.8.0.9 — 2026-09-15
**The developer bot, re-measured decision by decision — and an engine 2.8× faster.** Across the changes **The developer bot, re-measured decision by decision — and an engine 2.8× faster.** Across the changes
+28
View File
@@ -36,6 +36,34 @@ and `<ip>:<port>` are both expected — and browser storage is scoped to the ori
at one address must come back to that address, or they are a stranger with no token. Say so in the at one address must come back to that address, or they are a stranger with no token. Say so in the
UI at join time rather than letting someone discover it when they cannot get back in. UI at join time rather than letting someone discover it when they cannot get back in.
**A lost token is recoverable, administratively** (Gitea#33). Everything above makes the token the
single point of failure: it lives in one browser's storage, and a cleared profile, a private window or
a different browser ends the seat with the game still running and the session still on disk. Seen at a
real table — the returning player met an empty lobby while their token sat intact in `sessions.json`,
and the only way back was an administrator reading the file off the volume and the player pasting it
into a devtools console.
So there is a supported path, in two halves that are gated differently on purpose:
```
POST /api/games/<id>/claim { player } → { code, expiresAt, … } admin secret
POST /api/claim { code } → { token, gameId, player, gameCode }
```
**The link carries the code, never the token** — which is the rule three paragraphs up, applied. A
recovery link is exactly the sort of thing that gets pasted into a chat, so what travels in the URL is
single-use and expires in thirty minutes (`server/claims.ts`), and the page trades it for the real
token over a POST as it loads (`?claim=` in `web/main.ts`, which strips it from the address bar either
way). A leaked code is worthless once spent; a leaked token is the seat for the rest of the game.
**Minting is administrative; spending is not.** Deciding that a particular person has lost a
particular seat is a judgement no route can make safely — anyone able to mint their own code could
take any chair at the table. Spending needs no secret because the player following the link is the one
person in the story who holds none; the code *is* the authorisation, and it is the same shape
(unguessable, one-time) as the token it hands back. The codes are held in memory: they are minted on
demand and spent within minutes, so a restart dropping them is the right failure, and persisting them
would put a credential-equivalent on the volume to solve a problem measured in seconds.
Real accounts can be layered on later without touching the rules engine, which is exactly why Real accounts can be layered on later without touching the rules engine, which is exactly why
[`overview.md`](overview.md) keeps that boundary sharp. [`overview.md`](overview.md) keeps that boundary sharp.
+5 -5
View File
@@ -84,14 +84,14 @@ part of the road all change the entry point rather than the card's length.
The Mainline deck is 10 cards; the two Division Points are the fixed ends of The Mainline deck is 10 cards; the two Division Points are the fixed ends of
the Division and are not dealt. What each card does, in the words the game uses on screen: the Division and are not dealt. What each card does, in the words the game uses on screen:
- **Plains** — 1 region — one Stage each. · 1 Stage for every train — the printed speed is scenery. · One train at a time — anything following has to wait for it to clear. - **Plains** — 1 region — one Stage each. · 1 Stage for every train. · One train at a time — anything following has to wait for it to clear.
- **Curves** — 2 regions — one Stage each. · 2 Stages for every train — the printed speed is scenery. · One train at a time — anything following has to wait for it to clear. - **Curves** — 2 regions — one Stage each. · 2 Stages for every train. · One train at a time — anything following has to wait for it to clear.
- **Hilly** — 2 regions — one Stage each. · This card reads the train's FAST/SLOW rating: a fast train starts further along and crosses in 1 Stage, a slow one in 2 Stages. No other card cares which it is. · One train at a time — anything following has to wait for it to clear. - **Hilly** — 2 regions — one Stage each. · This card reads the train's FAST/SLOW rating: a fast train starts further along and crosses in 1 Stage, a slow one in 2 Stages. No other card cares which it is. · One train at a time — anything following has to wait for it to clear.
- **Heavy Grade** — 3 regions — one Stage each. · A grade, climbing eastward. 3 Stages to climb it and 3 Stages to run down, before help. Helpers start an UPHILL train a region further on; Brakeman does the same DOWNHILL and Airbrakes another again, and Airbrakes cannot be played without Brakeman. Never less than one Stage. · One train at a time — anything following has to wait for it to clear. - **Heavy Grade** — 3 regions — one Stage each. · A grade, climbing eastward. 3 Stages to climb it and 3 Stages to run down, before help. Helpers start an UPHILL train a region further on; Brakeman does the same DOWNHILL and Airbrakes another again, and Airbrakes cannot be played without Brakeman. Never less than one Stage. · One train at a time — anything following has to wait for it to clear.
- **Double Track** — 1 region — one Stage each. · 1 Stage for every train — the printed speed is scenery. · TRAINS MAY PASS — two trains may stand on this card at once, so a following train is not held behind a slower one. - **Double Track** — 1 region — one Stage each. · 1 Stage for every train. · TRAINS MAY PASS — two trains may stand on this card at once, so a following train is not held behind a slower one.
- **Uncontrolled Siding** — 2 regions — one Stage each. · A train with the card to itself starts past the back region and is across in 1 Stage. · UNCONTROLLED SIDING — arrive to find a train already here and you take the siding, a region behind it. You are not in the same place, so you do not run into it; it costs you the extra Stage instead. · One train at a time — anything following has to wait for it to clear. - **Uncontrolled Siding** — 2 regions — one Stage each. · A train with the card to itself starts past the back region and is across in 1 Stage. · UNCONTROLLED SIDING — arrive to find a train already here and you take the siding, a region behind it. You are not in the same place, so you do not run into it; it costs you the extra Stage instead. · One train at a time — anything following has to wait for it to clear.
- **Tunnel** — 2 regions — one Stage each. · 2 Stages for every train — the printed speed is scenery. · One train at a time — anything following has to wait for it to clear. - **Tunnel** — 2 regions — one Stage each. · 2 Stages for every train. · One train at a time — anything following has to wait for it to clear.
- **Trestle** — 1 region — one Stage each. · 1 Stage for every train — the printed speed is scenery. · One train at a time — anything following has to wait for it to clear. - **Trestle** — 1 region — one Stage each. · 1 Stage for every train. · One train at a time — anything following has to wait for it to clear.
- **Interchange** — 2 regions — one Stage each. · A train with the card to itself starts past the back region and is across in 1 Stage. · An Extra beginning its run here starts in the back region and takes the extra Stage. · One train at a time — anything following has to wait for it to clear. · Cars may be sorted into any new order here. - **Interchange** — 2 regions — one Stage each. · A train with the card to itself starts past the back region and is across in 1 Stage. · An Extra beginning its run here starts in the back region and takes the extra Stage. · One train at a time — anything following has to wait for it to clear. · Cars may be sorted into any new order here.
--- ---
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "station-master", "name": "station-master",
"version": "0.8.0.9", "version": "0.8.0.12",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Station Master — a railroad operations game", "description": "Station Master — a railroad operations game",
+10 -4
View File
@@ -70,11 +70,17 @@ function buildStamp(): string {
* and v0.7.6's fix to it both shipped correctly to `phoenix.local` and neither reached the browser * and v0.7.6's fix to it both shipped correctly to `phoenix.local` and neither reached the browser
* that asked for them (Jesse, twice, 2026-08-29 — "setup did not work"). * that asked for them (Jesse, twice, 2026-08-29 — "setup did not work").
* *
* The version plus the build's own timestamp is always distinct, needs nothing from the * A marker plus the build's own timestamp is always distinct, needs nothing from the environment,
* environment, and stays honest: two builds of the same commit ARE two deploys, and a cache key * and stays honest: two builds of the same commit ARE two deploys, and a cache key that says so
* that says so costs one refetch, while one that lies costs a release nobody receives. * costs one refetch, while one that lies costs a release nobody receives.
*
* NOT THE VERSION, which is what this used to lead with. The stamp below already begins with
* `v${pkg.version}`, so on exactly the builds that take this path — every `.s9pk`, which has no
* `.git` — the header read "v0.8.0.10 · 0.8.0.10-mfq2p1 · …" and the version appeared twice
* (Jesse, playtest 2026-09-16). The timestamp alone carries the uniqueness; the version is
* already said once, properly, at the front.
*/ */
let git = `${pkg.version}-${Date.now().toString(36)}`; let git = `nogit-${Date.now().toString(36)}`;
try { try {
const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root }) const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root })
.toString() .toString()
+30
View File
@@ -94,6 +94,18 @@ function nodeIndexOfOffice(s: GameState, seat: SeatIndex): number {
const step = (d: Direction): number => (d === 'east' ? 1 : -1); const step = (d: Direction): number => (d === 'east' ? 1 : -1);
/** The Division node a train is standing on — its Office, its Mainline card, or its Division Point. */
function nodeIndexOfTray(s: GameState, tray: CrewTray): number | null {
const at = tray.position;
if (at.at === 'grid') return nodeIndexOfOffice(s, at.seat);
if (at.at === 'mainline') return at.index;
if (at.at === 'divisionPoint') {
const side = at.side;
return s.division.nodes.findIndex((n) => n.kind === 'divisionPoint' && n.side === side);
}
return null;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// advance // advance
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1067,10 +1079,27 @@ function evaluateClearance(
* constrained. Each Office upgrade to a Control Point splits one in two and buys capacity. * constrained. Each Office upgrade to a Control Point splits one in two and buys capacity.
*/ */
const subdivision = subdivisions(s).find((group) => group.includes(targetIndex)) ?? [targetIndex]; const subdivision = subdivisions(s).find((group) => group.includes(targetIndex)) ?? [targetIndex];
/**
* ONLY WHAT IS AHEAD (Gitea#26). §8.1 asks about a train the considered train would FOLLOW, and one
* moving TOWARDS it — both of which are ahead of it. A Subdivision runs the length of every Whistle
* Post between two Control Points, so it can hold a train BEHIND the one departing: in the reported
* game X15 highballed west from an Office while X18, also westbound, was still crossing the card to its
* east. Counting X18 put a meaningless ruling to the Superintendent; holding X15 kept the Whistle Post's
* one A/D track full, and X18 arrived into it and was destroyed. A train behind and moving away is no
* threat at all.
*
* "Behind" is strictly behind the card the departing train stands on. A train on that same card is still
* counted, exactly as before: which of two trains sharing a card is in front is `entryConflict`'s region
* question, and this is not the place to answer it.
*/
const from = nodeIndexOfTray(s, tray);
const behind = (onCard: number): boolean =>
from !== null && from >= 0 && (tray.direction === 'east' ? onCard < from : onCard > from);
const occupants: { tray: TrayId; onCard: number }[] = []; const occupants: { tray: TrayId; onCard: number }[] = [];
for (const i of subdivision) { for (const i of subdivision) {
const n = s.division.nodes[i]; const n = s.division.nodes[i];
if (!n || n.kind !== 'mainline') continue; if (!n || n.kind !== 'mainline') continue;
if (behind(i)) continue;
for (const t of n.transits) if (t.tray) occupants.push({ tray: t.tray, onCard: i }); for (const t of n.transits) if (t.tray) occupants.push({ tray: t.tray, onCard: i });
} }
@@ -1455,6 +1484,7 @@ function arriveAtOffice(
trainNumber: tray.trainNumber ?? 0, trainNumber: tray.trainNumber ?? 0,
consist: tray.consist.map((c) => ({ ...c })), consist: tray.consist.map((c) => ({ ...c })),
office: officeProfile(area.tier).name, office: officeProfile(area.tier).name,
owner: playerAtSeat(s, seat),
expedited: isExpedited(tray), expedited: isExpedited(tray),
}); });
+9 -1
View File
@@ -1889,7 +1889,15 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
? REALIGNMENTS.find((r) => r.from === node.card)?.to ? REALIGNMENTS.find((r) => r.from === node.card)?.to
: undefined; : undefined;
return [ return [
{ type: 'mainlineModified', player, cardId: i.cardId, node: i.node, key, ...(became ? { became } : {}) }, {
type: 'mainlineModified',
player,
cardId: i.cardId,
node: i.node,
key,
...(node?.kind === 'mainline' ? { from: node.card } : {}),
...(became ? { became } : {}),
},
]; ];
} }
+11 -1
View File
@@ -772,7 +772,17 @@ export function mainlineDescription(kind: MainlineKind, gradeUp: Direction = 'ea
`${stages(run({}))}.`, `${stages(run({}))}.`,
); );
} else { } else {
parts.push(`${stages(run({}))} for every train — the printed speed is scenery.`); /**
* NOT A WORD ABOUT SPEED HERE — Jesse, playtest 2026-09-16.
*
* This read "the printed speed is scenery", which sent a player hunting the card for a number
* that is not drawn on it. The first rewrite said "fast or slow alike", which is true but raises
* the question on thirteen cards in order to answer it. **Exactly one card reads FAST/SLOW**:
* Hilly, the only profile with `speedStarts` (see the note above it). So the explanation belongs
* on that card, where the branch above already gives it, and everywhere else says nothing —
* silence is the honest answer when the rating genuinely does not apply.
*/
parts.push(`${stages(run({}))} for every train.`);
} }
if (kind === 'uncontrolledSiding') { if (kind === 'uncontrolledSiding') {
+14 -2
View File
@@ -87,7 +87,13 @@ export type GameEvent =
| { type: 'deckReshuffled'; order: CardId[]; rngState: number } | { type: 'deckReshuffled'; order: CardId[]; rngState: number }
/** `variant` is the chosen orientation (Gap 11); it must be replayable, so it rides the event. */ /** `variant` is the chosen orientation (Gap 11); it must be replayable, so it rides the event. */
| { type: 'cardPlayed'; player: PlayerIndex; cardId: CardId; placement?: GridCoord; variant?: number } | { type: 'cardPlayed'; player: PlayerIndex; cardId: CardId; placement?: GridCoord; variant?: number }
| { type: 'mainlineModified'; player: PlayerIndex; cardId: CardId; node: number; key: string; became?: string } /**
* `from` is the card's kind BEFORE the change, carried so the log can say what was realigned
* rather than only what it turned into (playtest, 2026-09-15: "it should state that the mainline
* card 3 curves was converted to plains"). Events are derived by replaying a save, never stored,
* so widening one strands nothing on disk.
*/
| { type: 'mainlineModified'; player: PlayerIndex; cardId: CardId; node: number; key: string; from?: string; became?: string }
/** §Q (Gitea#19) — a flag planted on one side of a district's Limits. */ /** §Q (Gitea#19) — a flag planted on one side of a district's Limits. */
| { type: 'redFlagsSet'; player: PlayerIndex; cardId: CardId; seat: SeatIndex; side: Direction } | { type: 'redFlagsSet'; player: PlayerIndex; cardId: CardId; seat: SeatIndex; side: Direction }
/** §Q (Gitea#19) — the flag stopped a train and came down with it. One card, one train. */ /** §Q (Gitea#19) — the flag stopped a train and came down with it. One card, one train. */
@@ -153,7 +159,13 @@ export type GameEvent =
* switched normally like any other arrival, but it has to be back on the Office square before the * switched normally like any other arrival, but it has to be back on the Office square before the
* next Mainline Phase begins, or `expediteFault` fires. * next Mainline Phase begins, or `expediteFault` fires.
*/ */
| { type: 'trainArrived'; trainNumber: number; consist: RollingStock[]; office: string; expedited: boolean } /**
* `owner` is WHOSE Office it reached — the district's player, not whoever is acting. The Mainline
* Phase has no actor, so nothing else in the line could name the seat, and the narration said only
* "ARRIVED at the Whistle Post" — every seat's Office has a tier, and at a four-seat table three of
* them are somebody else's (Jesse, playtest 2026-09-16).
*/
| { type: 'trainArrived'; trainNumber: number; consist: RollingStock[]; office: string; owner: PlayerIndex; expedited: boolean }
| { type: 'trainDiverted'; trainNumber: number; to: string; reason: string } | { type: 'trainDiverted'; trainNumber: number; to: string; reason: string }
/** /**
* The train ran the length of the Division and left it. `side` is the Division Point it left by, * The train ran the length of the Division and left it. `side` is the Division Point it left by,
+83
View File
@@ -0,0 +1,83 @@
/**
* SEAT RECOVERY CODES — Gitea#33.
*
* A session token is the only identity the game has (`lobby-and-sessions.md` §1) and it lives in
* exactly one place the player controls: their browser's `localStorage`, scoped to the origin they
* joined at. Lose that — a different browser, a cleared profile, a private window — and the seat is
* unreachable, because there is nothing else on the server that will accept a claim to it. Seen at a
* real table on 2026-09-16: the joining player came back to an empty lobby while their token sat
* intact in `sessions.json`, and the only way in was an administrator reading the file off the data
* volume and the player pasting it into a devtools console.
*
* THE CODE IS NOT THE TOKEN, AND THAT IS THE WHOLE POINT. §1 says to keep the token out of URLs so it
* is not shoulder-surfed or pasted into a chat — and a recovery link is exactly the kind of thing
* that gets pasted into a chat. So an administrator mints a SHORT-LIVED, SINGLE-USE code, the player
* opens a link carrying that, and the page trades it for the real token over the same connection it
* would have used anyway. A code that leaks after it is spent is worth nothing; a token that leaks is
* worth the seat for the rest of the game.
*
* PURE ON PURPOSE, like `lobby.ts` beside it: no sockets, no filesystem, no clock of its own. `now`
* is passed in so expiry is testable without faking timers, which is the only reason this file can be
* tested at all — nothing in this repo stands an HTTP server up to make requests against it.
*
* IN MEMORY, NOT ON DISK, which is a deliberate limit rather than an oversight. A restart drops every
* outstanding code, and that is the right failure: the codes are minted on demand and spent within
* minutes, the administrator is by definition present, and persisting them would put a credential-
* equivalent on the volume to solve a problem measured in seconds.
*/
import { randomUUID } from 'node:crypto';
/**
* Long enough to walk to the other room and read it out; short enough that a link left in a chat
* window is useless by the time anyone scrolls back to it.
*/
export const CLAIM_TTL_MS = 30 * 60 * 1000;
export type ClaimStore = {
/** Mint a code for one seat's token. Returns the code and when it stops working. */
mint(token: string, gameId: string, now: number, ttlMs?: number): { code: string; expiresAt: number };
/**
* Spend a code. Returns the seat it names, or null when the code is unknown, already spent or
* expired — deliberately one answer for all three, so a caller cannot probe which it was.
*/
redeem(code: string, now: number): { token: string; gameId: string } | null;
/** Outstanding, unexpired codes. For tests and for anything that wants to report the store's size. */
outstanding(now: number): number;
};
export function createClaimStore(): ClaimStore {
const claims = new Map<string, { token: string; gameId: string; expiresAt: number }>();
/** Expiry is lazy: there is no timer to own, start, stop or leak across a server's lifetime. */
const prune = (now: number): void => {
for (const [code, claim] of claims) if (claim.expiresAt <= now) claims.delete(code);
};
return {
mint(token, gameId, now, ttlMs = CLAIM_TTL_MS) {
prune(now);
// The same primitive the session tokens themselves use (`lobby.ts`), for the same reason: it
// has to be unguessable, and inventing a second scheme here would be inventing a weaker one.
const code = randomUUID();
const expiresAt = now + ttlMs;
claims.set(code, { token, gameId, expiresAt });
return { code, expiresAt };
},
redeem(code, now) {
prune(now);
const claim = claims.get(code);
if (!claim) return null;
// SINGLE USE. Deleted before the caller can do anything with it, so two browsers racing on the
// same link cannot both be seated — and a link that stays in someone's history is spent.
claims.delete(code);
return { token: claim.token, gameId: claim.gameId };
},
outstanding(now) {
prune(now);
return claims.size;
},
};
}
+108 -2
View File
@@ -37,6 +37,7 @@ import {
writeLobby, writeLobby,
writeSessions, writeSessions,
} from './persistence.ts'; } from './persistence.ts';
import { createClaimStore } from './claims.ts';
import { createSession } from './session.ts'; import { createSession } from './session.ts';
import type { GameSession, Push } from './session.ts'; import type { GameSession, Push } from './session.ts';
import { import {
@@ -170,6 +171,15 @@ export function startServer(opts: ServerOptions): void {
const games = opts.initialGames; const games = opts.initialGames;
const lobbies = opts.initialLobbies; const lobbies = opts.initialLobbies;
const sessions = opts.initialSessions; const sessions = opts.initialSessions;
/**
* Outstanding seat recovery codes — Gitea#33, `claims.ts`.
*
* In memory and not on the volume, deliberately: a code is minted on demand and spent within
* minutes with the administrator standing right there, so a restart dropping them all is the right
* failure. Persisting them would put a credential-equivalent on disk to solve a problem measured
* in seconds.
*/
const claims = createClaimStore();
const gameCodes = new Map<string, string>(); // gameCode -> gameId, for /api/lobby/join const gameCodes = new Map<string, string>(); // gameCode -> gameId, for /api/lobby/join
for (const [gameId, lobby] of lobbies) gameCodes.set(lobby.gameCode, gameId); for (const [gameId, lobby] of lobbies) gameCodes.set(lobby.gameCode, gameId);
@@ -322,6 +332,17 @@ export function startServer(opts: ServerOptions): void {
gameId, gameId,
gameCode: codes.get(gameId) ?? null, gameCode: codes.get(gameId) ?? null,
state: 'running' as const, state: 'running' as const,
/**
* WHICH SEATS A PERSON IS SITTING IN — Gitea#33.
*
* `playerNames` cannot answer it: a bot's name is just a name, and telling the two apart
* by matching "Bot 1" would be guessing at a label. `sessions` holds humans and only
* humans, so this is the fact rather than an inference — and it is what lets the seat
* recovery action offer real players instead of chairs no token was ever issued for.
*/
seatedPlayers: [...sessions.values()]
.filter((s) => s.gameId === gameId)
.map((s) => s.player),
...g.summary(), ...g.summary(),
})); }));
// A lobby has no game to summarize yet — it is reported as what it is, so an // A lobby has no game to summarize yet — it is reported as what it is, so an
@@ -340,14 +361,48 @@ export function startServer(opts: ServerOptions): void {
return; return;
} }
const match = /^\/api\/games\/([^/]+)(\/save)?$/.exec(url.pathname); const match = /^\/api\/games\/([^/]+)(\/save|\/claim)?$/.exec(url.pathname);
const gameId = match?.[1]; const gameId = match?.[1];
// Compared explicitly rather than tested for truthiness: with two suffixes in the group, a
// bare `match?.[2]` would let a GET on `/claim` fall into the `/save` branch below.
const suffix = match?.[2];
if (!gameId) { if (!gameId) {
sendJson(res, 404, { error: 'no such route' }); sendJson(res, 404, { error: 'no such route' });
return; return;
} }
if (match?.[2] && req.method === 'GET') { /**
* MINT A SEAT RECOVERY CODE FOR ONE PLAYER — Gitea#33.
*
* The token is the only identity this game has and it lives in one browser's `localStorage`;
* lose it and the seat is unreachable, because nothing else here will accept a claim to it.
* This is the supported way back, and it is administrative on purpose: whoever runs the
* server decides that a particular player has lost their seat, which is a judgement no
* automated route can make safely.
*
* IT HANDS BACK A CODE, NOT THE TOKEN. §1 says keep the token out of URLs, and the code is
* going into one. Short-lived and single-use (`claims.ts`), so a link left in a chat window
* is worth nothing by the time anyone finds it.
*/
if (suffix === '/claim' && req.method === 'POST') {
const body = (await readJson(req)) as { player?: number };
const ps = [...sessions.values()].find((s) => s.gameId === gameId && s.player === body.player);
if (!ps) {
sendJson(res, 404, { error: 'no such seat' });
return;
}
const { code, expiresAt } = claims.mint(ps.token, gameId, Date.now());
sendJson(res, 200, {
code,
expiresAt,
player: ps.player,
displayName: ps.displayName,
gameCode: codes.get(gameId) ?? null,
});
return;
}
if (suffix === '/save' && req.method === 'GET') {
const session = games.get(gameId); const session = games.get(gameId);
if (!session) { if (!session) {
sendJson(res, 404, { error: 'no such game' }); sendJson(res, 404, { error: 'no such game' });
@@ -656,6 +711,57 @@ export function startServer(opts: ServerOptions): void {
return; return;
} }
/**
* SPEND A SEAT RECOVERY CODE — Gitea#33, the other half of `/api/games/<id>/claim`.
*
* NOT GATED BY THE ADMIN SECRET, and it must not be: the player following the link is the one
* person in this story who holds no secret at all. The code IS the authorisation — unguessable,
* single-use and short-lived — which is the same shape as the session token it hands back, and
* why minting one is the administrative act rather than spending one.
*
* The token travels in the response BODY of a POST, never in a URL (`lobby-and-sessions.md`
* §1). One answer for unknown, spent and expired codes, so this cannot be used to probe which.
*/
if (url.pathname === '/api/claim' && req.method === 'POST') {
const body = (await readJson(req)) as { code?: string };
const claimed = typeof body.code === 'string' ? claims.redeem(body.code, Date.now()) : null;
const ps = claimed ? sessions.get(claimed.token) : undefined;
const live = ps ? games.get(ps.gameId) : undefined;
if (!claimed || !ps || !live) {
sendJson(res, 404, { error: 'no such claim' });
return;
}
const codes = new Map((await readIndex(opts.dataDir)).map((e) => [e.gameId, e.gameCode]));
sendJson(res, 200, {
token: ps.token,
gameId: ps.gameId,
player: ps.player,
gameCode: codes.get(ps.gameId) ?? '',
});
return;
}
/**
* THIS SEAT'S OWN GAME, AS A SAVE (playtest, 2026-09-15: "most of the time, I want to go ahead and
* just save it as a JSON file in my Downloads folder").
*
* The administrative export at `/api/games/<id>/save` is gated on the admin secret, which a player
* does not have and should not need: a save is the seed and the moves, and every one of those moves
* is already on this player's screen. So the seat's own session token is the gate, exactly as it is
* for `/api/stream` and `/api/intent` — it proves which game and which chair, and nothing else is
* disclosed. The page turns the JSON into a file (`main.ts`'s `downloadSave`).
*/
if (url.pathname === '/api/save' && req.method === 'GET') {
const ps = sessions.get(url.searchParams.get('token') ?? '');
const session = ps ? games.get(ps.gameId) : undefined;
if (!ps || !session) {
sendJson(res, 404, { error: 'no such game' });
return;
}
sendJson(res, 200, { gameId: ps.gameId, save: session.exportSave() });
return;
}
if (url.pathname === '/api/stream' && req.method === 'GET') { if (url.pathname === '/api/stream' && req.method === 'GET') {
const token = url.searchParams.get('token') ?? ''; const token = url.searchParams.get('token') ?? '';
const ps = sessions.get(token); const ps = sessions.get(token);
+56 -8
View File
@@ -42,6 +42,12 @@ export type DivisionRoster = {
actor: number | null; actor: number | null;
/** The player this map is being drawn for. */ /** The player this map is being drawn for. */
viewer: number; viewer: number;
/**
* Division nodes to flash — a Mainline card that has just become a different card (Realignment).
* Playtest, 2026-09-15: the log said a card had been converted and the map said nothing, so the one
* play that changes the Division itself was invisible on the map of it.
*/
flash?: readonly number[];
}; };
export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | null): string { export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | null): string {
@@ -131,9 +137,13 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
region?: number; region?: number;
direction?: string; direction?: string;
stagesLeft?: number; stagesLeft?: number;
/** Being made up at a Division Point right now, so the map can mark the train you are loading. */
beingMadeUp?: boolean;
}[]; }[];
cap: number | null; cap: number | null;
tip: string; tip: string;
/** Set on a card that has just changed under the players' feet — drawn with a brief pulse. */
flash?: boolean;
/** Which SEAT's district this cell belongs to, or null for Mainline and Division Points. */ /** Which SEAT's district this cell belongs to, or null for Mainline and Division Points. */
seat: number | null; seat: number | null;
/** Set on an Office cell when a roster was supplied: whose district this is. */ /** Set on an Office cell when a roster was supplied: whose district this is. */
@@ -168,7 +178,11 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
cells.push({ ...c, x: 0, y: 0 }); cells.push({ ...c, x: 0, y: 0 });
}; };
// The node's own index, so a cell can be matched against `roster.flash`. `continue` below skips the
// rest of the body, never this.
let nodeIndex = -1;
for (const n of nodes) { for (const n of nodes) {
nodeIndex++;
if (n.kind === 'office') { if (n.kind === 'office') {
const cap = n.capacity; const cap = n.capacity;
const ad = n.trains.flat(); const ad = n.trains.flat();
@@ -261,6 +275,7 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
push({ push({
kind: dp ? 'dp' : 'ml', kind: dp ? 'dp' : 'ml',
label: n.label, label: n.label,
...(roster?.flash?.includes(nodeIndex) ? { flash: true } : {}),
sub: n.capacity === null sub: n.capacity === null
? 'no limit — trains queue' ? 'no limit — trains queue'
: [free, inYard.length > 0 ? `${inYard.length} in the yard` : ''].filter(Boolean).join(' · '), : [free, inYard.length > 0 ? `${inYard.length} in the yard` : ''].filter(Boolean).join(' · '),
@@ -356,7 +371,7 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
cells.forEach((c) => { cells.forEach((c) => {
const full = c.cap !== null && c.trains.length >= c.cap; const full = c.cap !== null && c.trains.length >= c.cap;
out += `<g class="bs-dcell bs-d${c.kind}${full ? ' bs-full' : ''}" data-tip="${esc(c.tip)}">`; out += `<g class="bs-dcell bs-d${c.kind}${full ? ' bs-full' : ''}${c.flash ? ' bs-changed' : ''}" data-tip="${esc(c.tip)}">`;
out += `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${CH}" rx="5"/>`; out += `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${CH}" rx="5"/>`;
/** /**
* WHOSE IS IT, IS IT THEIR MOVE, AND IS IT MINE — answered by colour and one suffix rather * WHOSE IS IT, IS IT THEIR MOVE, AND IS IT MINE — answered by colour and one suffix rather
@@ -502,13 +517,18 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
const arrow = t.facing === 'w' ? '\u25c0' : '\u25b6'; const arrow = t.facing === 'w' ? '\u25c0' : '\u25b6';
const loaded = cars.filter((x) => /^loaded/.test(x) || /caboose/.test(x)).length; const loaded = cars.filter((x) => /^loaded/.test(x) || /caboose/.test(x)).length;
const label = cars.length === 0 ? `${t.label} ${arrow}` : `${t.label} ${arrow}${cars.length}`; const label = cars.length === 0 ? `${t.label} ${arrow}` : `${t.label} ${arrow}${cars.length}`;
// THE TRAIN THE MAKE-UP PANEL IS TALKING ABOUT. Amber, because that is what the rest of the
// page uses for "this is the thing you are acting on" (Jesse, playtest 2026-09-16).
const building = t.beingMadeUp === true;
const inRegion = c.regions > 1 && typeof t.region === 'number'; const inRegion = c.regions > 1 && typeof t.region === 'number';
const dir = t.direction === 'west' ? ' \u25c0 west' : t.direction === 'east' ? ' east \u25b6' : ''; const dir = t.direction === 'west' ? ' \u25c0 west' : t.direction === 'east' ? ' east \u25b6' : '';
const stages = const stages =
typeof t.stagesLeft === 'number' typeof t.stagesLeft === 'number'
? ` \u00b7 ${t.stagesLeft} Stage${t.stagesLeft === 1 ? '' : 's'} still to run across this card` ? ` \u00b7 ${t.stagesLeft} Stage${t.stagesLeft === 1 ? '' : 's'} still to run across this card`
: ''; : '';
out += `<g class="bs-train" data-tip="${esc(t.label)} \u2014 carrying ${esc(cars.join(', ') || 'no cars')}${ out += `<g class="bs-train${building ? ' bs-building' : ''}" data-tip="${esc(t.label)}${
building ? ' \u2014 BEING MADE UP NOW: add cars from the Division Yard' : ''
} \u2014 carrying ${esc(cars.join(', ') || 'no cars')}${
cars.length ? ` (${loaded} loaded)` : '' cars.length ? ` (${loaded} loaded)` : ''
}${inRegion ? ` \u00b7 region ${(t.region ?? 0) + 1} of ${c.regions}, counted west to east${dir}` : ''}${esc(stages)}${ }${inRegion ? ` \u00b7 region ${(t.region ?? 0) + 1} of ${c.regions}, counted west to east${dir}` : ''}${esc(stages)}${
// What the card prints. A train on the Mainline is exactly where "why did that leave without // What the card prints. A train on the Mainline is exactly where "why did that leave without
@@ -669,7 +689,17 @@ export function officeSvg(
const c0 = Math.min(...cols); const c0 = Math.min(...cols);
const c1 = Math.max(...cols); const c1 = Math.max(...cols);
const width = (c1 - c0 + 1) * (W + PAD); const width = (c1 - c0 + 1) * (W + PAD);
const height = (r1 - r0 + 1) * (H + PAD) + 4; /**
* A BAND BENEATH THE BOTTOM ROW FOR THE LIMITS LABELS, and only when there are labels to put in it.
*
* The bottom card's lower edge lands at `height - 7`, and the label's baseline was `height - 4` —
* so its 8px glyphs spanned `height - 12` to `height - 4` and the card's own border ran straight
* through the middle of the word (Jesse, playtest 2026-09-16: *"the text is split by the bottom
* border of the limits card… it should be printed directly beneath the card"*). Raising the text
* instead would have pushed it onto the card, over the rails; the room has to be made below.
*/
const limitBand = limits ? 14 : 0;
const height = (r1 - r0 + 1) * (H + PAD) + 4 + limitBand;
// Screen position of a card. Rows count DOWN from the top row, so the Running Track sits highest // Screen position of a card. Rows count DOWN from the top row, so the Running Track sits highest
// and the district hangs beneath it, as the rules describe it. // and the district hangs beneath it, as the rules describe it.
@@ -1205,7 +1235,9 @@ export function officeSvg(
if (limits) { if (limits) {
const edge = (x: number, side: string): string => const edge = (x: number, side: string): string =>
`<line class="bs-limitline" x1="${x}" y1="0" x2="${x}" y2="${height}"/>` + `<line class="bs-limitline" x1="${x}" y1="0" x2="${x}" y2="${height}"/>` +
`<text class="bs-limitlab" x="${x + (side === 'w' ? 4 : -4)}" y="${height - 4}" ` + // Baseline inside the band below the cards: the glyphs run from `height - 19` to `height - 11`
// and the bottom row's edge is at `height - 21`, so the whole word clears the card border.
`<text class="bs-limitlab" x="${x + (side === 'w' ? 4 : -4)}" y="${height - 11}" ` +
`text-anchor="${side === 'w' ? 'start' : 'end'}">LIMITS</text>`; `text-anchor="${side === 'w' ? 'start' : 'end'}">LIMITS</text>`;
out += edge(px(limits.west) - PAD / 2, 'w') + edge(px(limits.east) + W + PAD / 2, 'e'); out += edge(px(limits.west) - PAD / 2, 'w') + edge(px(limits.east) + W + PAD / 2, 'e');
} }
@@ -1248,9 +1280,18 @@ export const BOARD_CSS = `
and leave at the other, and a seated layout must not be read as a ring. */ and leave at the other, and a seated layout must not be read as a ring. */
.bs-stop line{stroke:#e0a060;stroke-width:2.6;stroke-linecap:round} .bs-stop line{stroke:#e0a060;stroke-width:2.6;stroke-linecap:round}
.bs-end{fill:#e0a060;font:10px ui-monospace,monospace;letter-spacing:.03em} .bs-end{fill:#e0a060;font:10px ui-monospace,monospace;letter-spacing:.03em}
/* The vertical bars a Mainline card is divided into (§2.1). Drawn faint: they are the ruler the /* A card that has just BECOME a different card (Realignment). The same amber the rest of the page
train is measured against, not something to look at instead of the train. */ spends on "it is happening here", pulsing only while the step that did it is on screen — so the
.bs-region{stroke:#4a5361;stroke-width:1.2;stroke-dasharray:3 3} change is seen on the map rather than only read in the log. */
.bs-dcell.bs-changed rect{stroke:#e0a060;stroke-width:2.4;animation:bs-changed-pulse 1.1s ease-in-out infinite}
@keyframes bs-changed-pulse{0%,100%{stroke-opacity:1}50%{stroke-opacity:.35}}
@media (prefers-reduced-motion: reduce){.bs-dcell.bs-changed rect{animation:none}}
/* The vertical bars a Mainline card is divided into (§2.1). They are the ruler the train is measured
against, not something to look at instead of the train — but they were drawn so faint they could
not be made out at all (Jesse, playtest 2026-09-16: "the dividing line is barely visible"). A
ruler you cannot read is not restraint, so this is lifted to the tie colour and given a longer
dash: still quieter than the rail, and now actually there. */
.bs-region{stroke:#98a3b2;stroke-width:1.6;stroke-dasharray:4 2}
/* #94 — the one red mark on the Division map, so it reads as a stop rather than as decoration. */ /* #94 — the one red mark on the Division map, so it reads as a stop rather than as decoration. */
.bs-flag line{stroke:#9aa3b0;stroke-width:1.6} .bs-flag line{stroke:#9aa3b0;stroke-width:1.6}
.bs-flag polygon{fill:#d2453f;stroke:#7d211d;stroke-width:0.8} .bs-flag polygon{fill:#d2453f;stroke:#7d211d;stroke-width:0.8}
@@ -1283,6 +1324,10 @@ export const BOARD_CSS = `
.bs-slot.bs-car-cch.bs-loaded{fill:rgba(90,169,230,.85)} .bs-slot.bs-car-cch.bs-loaded{fill:rgba(90,169,230,.85)}
.bs-slot.bs-car-cab.bs-loaded{fill:rgba(192,90,90,.85)} .bs-slot.bs-car-cab.bs-loaded{fill:rgba(192,90,90,.85)}
.bs-train rect{fill:#2f6b3d;stroke:#8fd6a0;stroke-width:1.2} .bs-train rect{fill:#2f6b3d;stroke:#8fd6a0;stroke-width:1.2}
/* The train the New Train phase is loading, in the page's action amber, so the make-up panel on the
right and the train on the map at the top left are visibly the same subject. */
.bs-train.bs-building rect{fill:#4a3a1c;stroke:#c8912f;stroke-width:2}
.bs-train.bs-building .bs-tlab{fill:#f2d49a}
.bs-crew rect{fill:#8a6d1f;stroke:#e0c060;stroke-width:1.2} .bs-crew rect{fill:#8a6d1f;stroke:#e0c060;stroke-width:1.2}
/* Each car in the train, in the order it is seated. Loaded is solid, empty is hollow, and the /* Each car in the train, in the order it is seated. Loaded is solid, empty is hollow, and the
engine is the one that carries the arrow — which is what makes "reverse" mean something. */ engine is the one that carries the arrow — which is what makes "reverse" mean something. */
@@ -1329,7 +1374,10 @@ export const BOARD_CSS = `
.bs-arrow{fill:#5f6b7a;font:10px ui-monospace,monospace} .bs-arrow{fill:#5f6b7a;font:10px ui-monospace,monospace}
.bs-cn{fill:#e6e9ee;font:600 11px ui-monospace,monospace} .bs-cn{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
.bs-coord{fill:#5f6b7a;font:9px ui-monospace,monospace} .bs-coord{fill:#5f6b7a;font:9px ui-monospace,monospace}
.bs-name{fill:#e6e9ee;font:600 11px ui-monospace,monospace} /* stroke:none (Gitea#24). A name takes the class \`bs-turn\` while it is that player's move, and \`.bs-turn\` is
also the turn ARROW's rule, which strokes its shape 2.4px grey. Declared after it, this keeps that
outline off the letters, which it smeared into an unreadable blur. */
.bs-name{fill:#e6e9ee;stroke:none;font:600 11px ui-monospace,monospace}
.bs-name.bs-you{fill:#5aa9e6} .bs-name.bs-you{fill:#5aa9e6}
/* Their move — wins over .bs-you when both apply, because whose turn it is changes every few /* Their move — wins over .bs-you when both apply, because whose turn it is changes every few
seconds and which railroad is yours never does. seconds and which railroad is yours never does.
+37 -18
View File
@@ -15,7 +15,7 @@
* panel cannot drift from the rules. * panel cannot drift from the rules.
*/ */
import { MAX_CONSIST, crewTrayCount } from '../engine/content.ts'; import { MAINLINE_PROFILES, MAX_CONSIST, crewTrayCount } from '../engine/content.ts';
import { adTrackCount, coordKey, seatOf, turnOf } from '../engine/state.ts'; import { adTrackCount, coordKey, seatOf, turnOf } from '../engine/state.ts';
import type { GameState, GridCoord, PlayerIndex, RollingStock, SeatIndex, TrayId } from '../engine/state.ts'; import type { GameState, GridCoord, PlayerIndex, RollingStock, SeatIndex, TrayId } from '../engine/state.ts';
import { areaOf, canAdvanceLoad, canBoard, canDetrain, canStartLoad, facilityCarType, facilityCarTypes, freightRuleSpentHere, isFreight, laborersLeft, movesFor, passengerRefusal, portersLeft } from '../engine/apply.ts'; import { areaOf, canAdvanceLoad, canBoard, canDetrain, canStartLoad, facilityCarType, facilityCarTypes, freightRuleSpentHere, isFreight, laborersLeft, movesFor, passengerRefusal, portersLeft } from '../engine/apply.ts';
@@ -134,7 +134,7 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
case 'actorChanged': case 'actorChanged':
return { return {
tone: 'quiet', tone: 'quiet',
text: e.player === null ? 'No player acts — automatic phase' : `Player ${e.player} to act`, text: e.player === null ? 'No player acts — automatic phase' : 'to act',
}; };
// -- local operations // -- local operations
@@ -211,13 +211,19 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
? `Played ${card(e.cardId)} onto ${at(e.placement)}` ? `Played ${card(e.cardId)} onto ${at(e.placement)}`
: `Played ${card(e.cardId)}`, : `Played ${card(e.cardId)}`,
}; };
case 'mainlineModified': case 'mainlineModified': {
// WHICH CARD, NOT JUST WHICH WAY IT WENT. "Mainline card 3 converted to plains" left the reader
// to remember what card 3 had been (playtest, 2026-09-15), and the card it WAS is the half that
// says what the play was worth.
const kindName = (k: string | undefined): string =>
MAINLINE_PROFILES.find((m) => m.kind === k)?.name ?? k ?? 'that card';
return { return {
tone: 'plain', tone: 'plain',
text: e.became text: e.became
? `Realignment: Mainline card ${e.node} converted to ${e.became}` ? `Realignment: Mainline card ${e.node}, ${kindName(e.from)}, converted to ${kindName(e.became)}`
: `Played ${e.key} on Mainline card ${e.node}`, : `Played ${e.key} on Mainline card ${e.node}${e.from ? ` (${kindName(e.from)})` : ''}`,
}; };
}
case 'redFlagSpent': case 'redFlagSpent':
return { return {
tone: 'good', tone: 'good',
@@ -225,8 +231,8 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
}; };
case 'redFlagRuled': case 'redFlagRuled':
return e.flag return e.flag
? { tone: 'plain', text: `Player ${e.player} flagged the approaching train` } ? { tone: 'plain', text: 'Flagged the approaching train' }
: { tone: 'plain', text: `Player ${e.player} waved the train through` }; : { tone: 'plain', text: 'Waved the train through' };
case 'redFlagsSet': case 'redFlagsSet':
return { return {
tone: 'good', tone: 'good',
@@ -337,12 +343,25 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
* arrival; the only difference is what happens if it is left on Secondary Track when the next * arrival; the only difference is what happens if it is left on Secondary Track when the next
* Mainline Phase begins (`expediteFault`). * Mainline Phase begins (`expediteFault`).
*/ */
return { /**
tone: 'plain', * WHOSE OFFICE, AND WHOSE TRAIN TO WORK — Jesse, playtest 2026-09-16.
text: e.expedited *
? `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — its card prints EXPEDITE, so keep it on the Office square: parked anywhere else in the district when the next Mainline Phase begins costs a Revenue point. It works and switches normally in the meantime.` * This said "ARRIVED at the Whistle Post" and then "You can work it in Cargo now". Both halves
: `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — it stands here for the rest of this Stage. You can work it in Cargo now, switch it in the NEXT Stage's Local Operations, and it departs in that Stage's Mainline Phase.`, * are wrong at a table of four: every seat has an Office, so the tier alone does not say which
}; * district the train is standing in, and the reader is usually NOT its Station Master — the
* line was telling three players they could work a train they cannot touch.
*/
{
const name = ctx.playerName?.(e.owner) ?? null;
const whose = name === null ? `the ${e.office}` : `${name}'s ${e.office}`;
const worker = name === null ? 'Its Station Master' : name;
return {
tone: 'plain',
text: e.expedited
? `Train ${e.trainNumber} ARRIVED at ${whose} carrying ${carsLabel(e.consist)} — its card prints EXPEDITE, so it must stay on the Office square: parked anywhere else in that district when the next Mainline Phase begins costs a Revenue point. It works and switches normally in the meantime.`
: `Train ${e.trainNumber} ARRIVED at ${whose} carrying ${carsLabel(e.consist)} — it stands there for the rest of this Stage. ${worker} can work it in Cargo now and switch it in the NEXT Stage's Local Operations, and it departs in that Stage's Mainline Phase.`,
};
}
case 'expediteFault': case 'expediteFault':
return { return {
tone: 'bad', tone: 'bad',
@@ -496,13 +515,13 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
? { tone: 'bad', text: `${e.reason.toUpperCase()} · ${e.delta} Revenue (now ${e.total})` } ? { tone: 'bad', text: `${e.reason.toUpperCase()} · ${e.delta} Revenue (now ${e.total})` }
: { tone: 'good', text: `+${e.delta} Revenue (now ${e.total}) — ${e.reason}` }; : { tone: 'good', text: `+${e.delta} Revenue (now ${e.total}) — ${e.reason}` };
case 'phaseEnded': case 'phaseEnded':
return { tone: 'quiet', text: `Player ${e.player} finished ${phaseLabel(e.phase)}` }; return { tone: 'quiet', text: `Finished ${phaseLabel(e.phase)}` };
// -- §3.3, extended play (Gitea#11) // -- §3.3, extended play (Gitea#11)
case 'extensionVoted': case 'extensionVoted':
return e.agree return e.agree
? { tone: 'plain', text: `Player ${e.player} would play one more Day` } ? { tone: 'plain', text: 'Would play one more Day' }
: { tone: 'plain', text: `Player ${e.player} called time — the game ends here` }; : { tone: 'plain', text: 'Called time — the game ends here' };
case 'dayExtended': case 'dayExtended':
return { tone: 'clock', text: `── The table plays on: Day ${e.day} is added to the timetable ──` }; return { tone: 'clock', text: `── The table plays on: Day ${e.day} is added to the timetable ──` };
case 'playConcluded': case 'playConcluded':
@@ -511,8 +530,8 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
// -- §11, the Yard Office (Gitea#5) // -- §11, the Yard Office (Gitea#5)
case 'yardOfficeRuled': case 'yardOfficeRuled':
return e.take return e.take
? { tone: 'plain', text: `Player ${e.player} sent ${train(e.trainId)} into the Yard Office` } ? { tone: 'plain', text: `Sent ${train(e.trainId)} into the Yard Office` }
: { tone: 'plain', text: `Player ${e.player} kept ${train(e.trainId)} at the Train Order Office` }; : { tone: 'plain', text: `Kept ${train(e.trainId)} at the Train Order Office` };
} }
} }
+19
View File
@@ -157,6 +157,25 @@ export type PileKey = 'home' | 'salvage' | `dept${number}`;
* | `card.play` | `salvage`, or nothing here when it lands on the board | * | `card.play` | `salvage`, or nothing here when it lands on the board |
* | switching, new trains | nothing here — those show on the board itself | * | switching, new trains | nothing here — those show on the board itself |
*/ */
/**
* Mainline cards that became a different card between two public boards — a Realignment, which is the
* one play that changes the Division itself.
*
* Playtest, 2026-09-15: *"is it possible to flash the mainline card when it gets changed by realignment?
* This would be more obvious to see what's happening on the map."* Detected the same way `changedPiles`
* detects a pile moving — by comparing the two boards the queue already holds — rather than by reading
* the event, so the flash lands with the step that shows it and not when the intent arrived.
*/
export function changedDivisionCards(before: PublicFrame | null, after: PublicFrame): number[] {
if (before === null) return [];
const out: number[] = [];
after.division.forEach((node, i) => {
const was = before.division[i];
if (was && was.kind === 'ml' && node.kind === 'ml' && was.label !== node.label) out.push(i);
});
return out;
}
export function changedPiles(before: PublicFrame | null, after: PublicFrame): PileKey[] { export function changedPiles(before: PublicFrame | null, after: PublicFrame): PileKey[] {
if (before === null) return []; if (before === null) return [];
const out: PileKey[] = []; const out: PileKey[] = [];
+2
View File
@@ -79,6 +79,8 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
const narrateCtx = { const narrateCtx = {
cardName: (id: string) => cardName(s, id), cardName: (id: string) => cardName(s, id),
trainName: (id: string) => trainName(s, id), trainName: (id: string) => trainName(s, id),
// A player index is not a seat index, so the fallback names no number at all — see `web/game.ts`.
playerName: (p: number) => s.players[p]?.name ?? 'another player',
}; };
// Tracks whether a phase did anything, so an empty one can say so rather than ending silently. // Tracks whether a phase did anything, so an empty one can say so rather than ending silently.
+20 -2
View File
@@ -106,7 +106,25 @@ export function turnChartHtml(f: TurnChartFrame, actorName: string | null, super
* and answers who; `awaiting` says what, because "waiting on Bob" with no more than that is a * and answers who; `awaiting` says what, because "waiting on Bob" with no more than that is a
* game that looks stuck to everyone except Bob. * game that looks stuck to everyone except Bob.
*/ */
const who = actorName ?? 'nobody — the Division is running itself'; /**
* AN AUTOMATIC PHASE WAITS ON NOBODY, so it says what it is DOING instead of apologising.
*
* "nobody — the Division is running itself" reached the answer by negation, and left a player
* reading a line whose subject was an absence (Jesse, playtest 2026-09-16: it should say "waiting
* on <player>", or describe what the engine is doing — "the Division is moving trains during the
* mainline phase"). The phase's NAME is already printed on the line directly above this one, so
* these describe the work rather than repeating the label.
*/
const DOING: Record<string, string> = {
mainline: 'the Division is moving trains',
newTrain: "the Division is building this Stage's trains",
loadUnload: 'the Division is working cargo',
shiftChange: 'the Division is changing shifts',
};
// Local Operations always has an actor, so its entry is the fallback rather than a case.
const who = actorName ?? DOING[f.phaseKey] ?? 'the Division is running itself';
// Only a person is WAITED ON. The Division is not waiting; it is working.
const waiting = actorName === null ? '' : 'waiting on ';
const asked = f.awaiting const asked = f.awaiting
? ` <span class="tc-asks">${esc(f.awaiting.asks)} · ${esc(f.awaiting.train)}</span>` ? ` <span class="tc-asks">${esc(f.awaiting.asks)} · ${esc(f.awaiting.train)}</span>`
: ''; : '';
@@ -120,7 +138,7 @@ export function turnChartHtml(f: TurnChartFrame, actorName: string | null, super
`<div class="tc-when"><b>Day ${f.day}</b><span>Stage ${f.stage} of 12</span>` + `<div class="tc-when"><b>Day ${f.day}</b><span>Stage ${f.stage} of 12</span>` +
`<span class="dim">${esc(f.clock)}</span></div>` + `<span class="dim">${esc(f.clock)}</span></div>` +
`<div class="tc-now">phase <b>${esc(f.phase)}</b></div>` + `<div class="tc-now">phase <b>${esc(f.phase)}</b></div>` +
`<div class="tc-who">waiting on <b>${esc(who)}</b>${asked}</div>` + `<div class="tc-who">${waiting}<b>${esc(who)}</b>${asked}</div>` +
// THE FEDORA RIDES AT THE END OF THE PHASE ROW (`TODO.md` #29, Jesse). It sat on its own line // THE FEDORA RIDES AT THE END OF THE PHASE ROW (`TODO.md` #29, Jesse). It sat on its own line
// between the phases and everything above them, which put a thing that changes every third // between the phases and everything above them, which put a thing that changes every third
// Stage in the middle of the things that change every Stage. The row it belongs beside is the // Stage in the middle of the things that change every Stage. The row it belongs beside is the
+33
View File
@@ -16,6 +16,7 @@ import {
areaOf, areaOf,
destinationsFor, destinationsFor,
facilityCarType, facilityCarType,
isBeingMadeUp,
laborersLeft, laborersLeft,
movesFor, movesFor,
ownCutFor, ownCutFor,
@@ -276,6 +277,15 @@ export type TrainChip = {
* it belongs in the tooltip, where there is room to say which it is. * it belongs in the tooltip, where there is room to say which it is.
*/ */
stagesLeft?: number; stagesLeft?: number;
/**
* BEING MADE UP RIGHT NOW — §7's round, one car at a time, at a Division Point.
*
* The make-up panel names the train and the yard chips load it, and both are in the right-hand
* column; the train itself is drawn on the Division strip at the top left, looking exactly like
* every other chip on the map. So the two halves of the same activity never pointed at each other
* (Jesse, playtest 2026-09-16). Absent rather than false everywhere else, like `region` above.
*/
beingMadeUp?: true;
}; };
/** /**
* One card of a player's Running Track, as the Division sees it. * One card of a player's Running Track, as the Division sees it.
@@ -720,6 +730,26 @@ function suppressedGrants(modifiers: string[], f: Facility): string[] {
for (const key of modifiers) { for (const key of modifiers) {
const m = MODIFIER_PROFILES.find((p) => p.kind === key); const m = MODIFIER_PROFILES.find((p) => p.kind === key);
if (!m) continue; if (!m) continue;
/**
* A WHISTLE POST TAKES NOTHING AT ALL, AND SAYING "IT ONLY RECEIVES" WOULD BE A LIE.
*
* Jesse, playtest 2026-09-16: a Restaurant appeared to do nothing. It does nothing — a Whistle
* Post is not a Passenger Facility, so it allows neither direction and has 0 capacity each way;
* the engine's `usableGrant` discards the capacity while the porter is granted regardless, which
* leaves a porter with nothing to carry. Playing it there stays LEGAL on Jesse's call, so the
* card is not wasted — it starts working the moment the Office is upgraded — but the panel has
* to say so, or the player is left believing the card is broken.
*
* Only a Whistle Post can reach this: every freight flow allows at least one direction, and
* every Office above the first allows both.
*/
if (f.kind === 'passenger' && !f.allows.outbound && !f.allows.inbound) {
out.push(
`${m.name}: DORMANT — a Whistle Post works no passengers at all, so nothing this card ` +
`grants is in use yet. It all starts working when the Office is upgraded to a Depot.`,
);
continue;
}
if (m.addOut > 0 && !f.allows.outbound) { if (m.addOut > 0 && !f.allows.outbound) {
out.push(`${m.name}: +${m.addOut} outbound has no effect here — this facility only receives`); out.push(`${m.name}: +${m.addOut} outbound has no effect here — this facility only receives`);
} }
@@ -2367,6 +2397,9 @@ function trainChip(s: GameState, id: string): TrainChip {
engineAt: at, engineAt: at,
facing: railFacingOf(t), facing: railFacingOf(t),
what: t.trainNumber === null ? 'A local crew — no timetable, no card, no special rules.' : trainRules(t), what: t.trainNumber === null ? 'A local crew — no timetable, no card, no special rules.' : trainRules(t),
// Conditional spread, not `beingMadeUp: isBeingMadeUp(t)`: the field is optional-and-true, and
// `exactOptionalPropertyTypes` refuses an explicit `false` for it.
...(isBeingMadeUp(t) ? { beingMadeUp: true as const } : {}),
}; };
} }
+65 -2
View File
@@ -663,6 +663,14 @@ export type Menu = {
makeUp: { makeUp: {
trayId: string; trayId: string;
title: string; title: string;
/**
* What the card still wants, after what is already coupled up — "1 boxcar/hopper + 1 caboose".
*
* The title says what the card CALLS FOR and never changes as cars go on, so a player had to
* diff it against the consist drawn on the Division map, in the other column. Null when the
* train is complete and only the send-it-out button is left.
*/
needs: string | null;
cars: MakeUpAction[]; cars: MakeUpAction[];
pass: number | null; pass: number | null;
/** /**
@@ -803,6 +811,7 @@ export function actionMenu(game: Game, seat: PlayerIndex = 0): Menu {
? { ? {
trayId: filling, trayId: filling,
title: consistTitle(game, filling) ?? 'Making up the train', title: consistTitle(game, filling) ?? 'Making up the train',
needs: consistNeeds(game, filling),
cars: makeUpCars, cars: makeUpCars,
pass, pass,
advice: makeUpAdvice(game, filling, makeUpCars), advice: makeUpAdvice(game, filling, makeUpCars),
@@ -955,6 +964,40 @@ function consistTitle(game: Game, trayId: string): string | null {
return trainCardTitle(tray.trainNumber ?? 0, tray.trainIsExtra); return trainCardTitle(tray.trainNumber ?? 0, tray.trainIsExtra);
} }
/**
* WHAT THE TRAIN STILL WANTS — the card's demand minus what is already on it.
*
* The heading says "its card calls for 3 boxcar/hopper + 1 caboose" and goes on saying it whether
* you have added none or three; the cars themselves are drawn on the Division map, in the other
* column. So the one question a player actually has while clicking — what is left? — was the one
* thing on screen that had to be worked out by eye, across two panels (Jesse, playtest 2026-09-16).
*
* BY CATEGORY, exactly as `acceptsCar` counts them, so this cannot promise a car the engine would
* then refuse. Null when nothing is outstanding.
*/
function consistNeeds(game: Game, trayId: string): string | null {
const tray = game.state.trays.get(trayId);
if (!tray) return null;
const p = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
if (!p) return null;
const cat = (t: string): 'coach' | 'caboose' | 'freight' =>
t === 'coach' ? 'coach' : t === 'caboose' ? 'caboose' : 'freight';
const have = (k: 'coach' | 'caboose' | 'freight'): number =>
tray.consist.filter((c) => cat(c.type) === k).length;
const parts: string[] = [];
const freight = p.consist.freight - have('freight');
const coach = p.consist.coach - have('coach');
const caboose = p.consist.caboose - have('caboose');
if (freight > 0) {
parts.push(`${freight} ${p.consist.freightTypes?.join('/') ?? 'freight'}${p.consist.emptiesOnly ? ' (empties only)' : ''}`);
}
if (coach > 0) parts.push(`${coach} coach${coach > 1 ? 'es' : ''}`);
if (caboose > 0) parts.push(`${caboose} caboose`);
return parts.length > 0 ? parts.join(' + ') : null;
}
/** /**
* "Making up Extra X22 “Pee-Dee”: its card calls for 1 caboose — Per-diem train…" * "Making up Extra X22 “Pee-Dee”: its card calls for 1 caboose — Per-diem train…"
* *
@@ -1234,6 +1277,13 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
const n = narrate(e, { const n = narrate(e, {
cardName: (id) => cardName(game.state, id), cardName: (id) => cardName(game.state, id),
trainName: (id) => trainName(game.state, id), trainName: (id) => trainName(game.state, id),
// Whose district a train reached is not the actor — the Mainline Phase has none — so the
// narration resolves the name itself rather than being prefixed with one by the code below.
// NO NUMBER IN THE FALLBACK. This is a PLAYER index, and a player is not a seat — seats rotate
// under Employee Rotation, which is why `seatOf` exists — so "Seat 3" here would be a wrong
// number dressed as a right one, and `session.test.ts` rightly refuses any raw index shown to
// a person. Every caller passes real names; an unnamed player is anonymous rather than mislabelled.
playerName: (p) => game.state.players[p]?.name ?? 'another player',
}); });
/** /**
* A BLIND DRAW IS PUBLIC; WHICH CARD CAME UP IS NOT (Gitea#20 step 1). * A BLIND DRAW IS PUBLIC; WHICH CARD CAME UP IS NOT (Gitea#20 step 1).
@@ -1255,9 +1305,22 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
const said = blindDraw ? 'Drew a card from the Home Office deck' : n.text; const said = blindDraw ? 'Drew a card from the Home Office deck' : n.text;
// "Chose to DRAW a card" does not say WHO, which is unreadable the moment there is more than // "Chose to DRAW a card" does not say WHO, which is unreadable the moment there is more than
// one seat. Only events the player caused are attributed; the Division running itself is not. // one seat. Only events the player caused are attributed; the Division running itself is not.
/**
* A RULING IS MADE AS SUPERINTENDENT, NOT AS YOURSELF (playtest, 2026-09-15: "maybe it could say
* 'Superintendent Player Tom', so it's clear they got the move because they're Superintendent").
* These three are the only moves a player makes out of turn, by holding the office: §8.1's
* clearance, §11's Yard Office offer and §Q's Red Flag prompt. `clearanceGiven` carries no
* player at all — the office made it, whoever holds it — so the actor is what names it.
*/
const RULINGS = ['clearanceGiven', 'yardOfficeRuled', 'redFlagRuled'];
const ruling = RULINGS.includes(e.type) && who !== null;
const mine = who !== null && 'player' in e; const mine = who !== null && 'player' in e;
const text = mine ? `Player ${who} ${uncapitalise(said)}` : said; const text = ruling
game.log.push({ text, tone: mine ? 'act' : n.tone }); ? `Superintendent Player ${who} ${uncapitalise(said)}`
: mine
? `Player ${who} ${uncapitalise(said)}`
: said;
game.log.push({ text, tone: mine || ruling ? 'act' : n.tone });
} }
game.cues.push(...cuesFor(events)); game.cues.push(...cuesFor(events));
+2 -1
View File
@@ -107,7 +107,8 @@ function explain(code: unknown, fallback: string): string {
return messages[key] ?? (key !== '' ? key : fallback); return messages[key] ?? (key !== '' ? key : fallback);
} }
async function postJson(path: string, body: unknown): Promise<{ status: number; body: Record<string, unknown> }> { /** Exported for `main.ts`'s seat-recovery path (Gitea#33), so there is one JSON POST on this page. */
export async function postJson(path: string, body: unknown): Promise<{ status: number; body: Record<string, unknown> }> {
const res = await fetch(path, { const res = await fetch(path, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
+287 -22
View File
@@ -25,9 +25,9 @@ import type { LocalSession, Session } from './session.ts';
import { createLocalSession, createRemoteSession } from './session.ts'; import { createLocalSession, createRemoteSession } from './session.ts';
import type { PlayerIndex } from '../engine/state.ts'; import type { PlayerIndex } from '../engine/state.ts';
import type { PublicDistrict } from '../sim/view.ts'; import type { PublicDistrict } from '../sim/view.ts';
import { createStepQueue } from './step-queue.ts'; import { actorOnScreen, createStepQueue } from './step-queue.ts';
import { PACE_LEVELS } from '../sim/pacing.ts'; import { PACE_LEVELS } from '../sim/pacing.ts';
import { notice, prefillCode, runLobby } from './lobby.ts'; import { notice, postJson, prefillCode, runLobby } from './lobby.ts';
import type { LobbyReady } from './lobby.ts'; import type { LobbyReady } from './lobby.ts';
import { import {
closestPreset, closestPreset,
@@ -231,7 +231,9 @@ function renderWatching(f?: Frame): void {
return; return;
} }
row.hidden = false; row.hidden = false;
$('watching-behind').textContent = behind === 0 ? 'catching up' : `${behind} behind`; // A held queue stops counting down, so the counter has to say why rather than look stuck.
$('watching-behind').textContent =
(behind === 0 ? 'catching up' : `${behind} behind`) + (stepQueue.paused() ? ' · paused' : '');
/** /**
* 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.
* *
@@ -273,6 +275,56 @@ function renderWatching(f?: Frame): void {
$('watching-skip').onclick = () => { $('watching-skip').onclick = () => {
if (stepQueue.skip()) render(); if (stepQueue.skip()) render();
}; };
/**
* PAUSE IS SKIP'S OPPOSITE, and shares its row for that reason.
*
* The label says what pressing it DOES, so it flips to Resume while held — the same rule the
* district's three-mode control settled on, for the same reason: a label that reports state reads
* as a status line and gets skipped over.
*
* `performance.now()` because that is the clock `requestAnimationFrame` hands `advance()`; mixing
* in `Date.now()` would shift the deadline by the page's whole lifetime. Guarded because the
* static build is loaded head-first against a DOM stub with no `performance`.
*/
const pauseBtn = $('watching-pause');
pauseBtn.textContent = stepQueue.paused() ? 'Resume' : 'Pause';
pauseBtn.onclick = () => {
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
if (stepQueue.paused()) {
stepQueue.resume(now);
// The loop exits whenever the queue stops being busy; restart it rather than assume it survived.
startAnimationLoop();
} else {
stepQueue.pause(now);
}
render();
};
}
/**
* THE TABLE AS IT IS ON SCREEN — the Day, the Stage, the clock, the phase and the Fedora.
*
* "WHEN PLAYER JESSE IS 5 BEHIND, IT SHOULD ALWAYS LOOK LIKE HE'S 5 BEHIND" (playtest, 2026-09-16).
* The board, the district panel and the caption row have followed the queue since v0.8.0; the turn
* chart never did. So a player watching three bots play out a Stage saw their cards moving under a
* chart that had already ticked over to the next phase — the one part of the screen quietly
* insisting the game was somewhere else. Being behind is fine and is stated plainly by the counter;
* being behind on some of the screen and level on the rest is what makes it unreadable.
*
* Only while the queue is actually behind. At rest this IS the live frame, so nothing downstream
* needs to know which of the two it was handed.
*/
function shownTable(f: Frame): Pick<Frame, 'day' | 'stage' | 'clock' | 'phase' | 'phaseKey' | 'superintendent'> {
const pub = stepQueue.current();
if (!pub || !stepQueue.busy()) return f;
return {
day: pub.day,
stage: pub.stage,
clock: pub.clock,
phase: pub.phase,
phaseKey: pub.phaseKey,
superintendent: pub.superintendent,
};
} }
function watchedDistrict(f: Frame): PublicDistrict | null { function watchedDistrict(f: Frame): PublicDistrict | null {
@@ -285,6 +337,10 @@ function watchedDistrict(f: Frame): PublicDistrict | null {
* A PHASE STEP NAMES NOBODY — the Mainline advances itself — so it falls through to the actor, * A PHASE STEP NAMES NOBODY — the Mainline advances itself — so it falls through to the actor,
* which keeps the board where it was instead of snapping home mid-sequence. * which keeps the board where it was instead of snapping home mid-sequence.
*/ */
// A deliberate look wins over whoever happens to be acting, for this one render (see `peekPlayer`).
if (peekPlayer !== null && peekPlayer !== f.viewer) {
return pub.districts.find((d) => d.player === peekPlayer) ?? null;
}
let player: PlayerIndex | null = f.actor; let player: PlayerIndex | null = f.actor;
if (stepQueue.busy()) { if (stepQueue.busy()) {
const acting = stepQueue.showing()?.player; const acting = stepQueue.showing()?.player;
@@ -430,6 +486,22 @@ let lastDay: number | null = null;
* ones. Cleared whenever the named crew stops being one of the choices. * ones. Cleared whenever the named crew stops being one of the choices.
*/ */
let selectedCrew: string | null = null; let selectedCrew: string | null = null;
/**
* A ONE-RENDER LOOK AT SOMEBODY ELSE'S OFFICE AREA — Jesse, playtest 2026-09-16.
*
* Deliberately NOT a mode. It survives exactly the render its own click causes and is cleared at the
* end of `renderDistrict`, so the panel is back to following whoever is acting the next time
* anything redraws. That is the whole design, in his words: *"if you want to study someone else's
* office area, you should do it while it's your turn to move, or put the backlog on pause, then look
* at their area, and when you're done looking, resume."*
*
* A sticky pin would have to answer what happens when the game moves on beneath it — and the honest
* answers are all bad: silently snap home, or leave a player staring at a stale board with the game
* waiting on them. Pause already means "hold everything", so it is the right lever for a long look,
* and this stays a glance.
*/
let peekPlayer: PlayerIndex | null = null;
const FOCUS_PHASES = new Set(['localOps', 'loadUnload']); const FOCUS_PHASES = new Set(['localOps', 'loadUnload']);
/** The crew whose squares the board is drawing: the chosen one, or the only one there is. */ /** The crew whose squares the board is drawing: the chosen one, or the only one there is. */
@@ -493,12 +565,20 @@ function piecePreview(links: string[], label: string): string {
* are in the Day the same way and with the same violet highlight. It used to live here alone. * are in the Day the same way and with the same violet highlight. It used to live here alone.
*/ */
function renderTurnChart(f: Frame): void { function renderTurnChart(f: Frame): void {
const actorName = f.actor === null ? null : (f.players[f.actor]?.name ?? null); // The move on screen, not the live one, while the board is still catching up (Gitea#25).
const { actor, replaying } = actorOnScreen(stepQueue, f.actor);
const actorName = actor === null ? null : (f.players[actor]?.name ?? null);
// The Day, Stage and phase of the step being shown, so the whole screen reports one moment.
const table = shownTable(f);
// Named only at a table with more than one seat: in solitaire the Fedora is always yours, and a // Named only at a table with more than one seat: in solitaire the Fedora is always yours, and a
// chip that can never change is a chip to read past. // chip that can never change is a chip to read past.
const superName = const superName =
f.players.length > 1 ? (f.players.find((p) => p.index === f.superintendent)?.name ?? null) : null; f.players.length > 1 ? (f.players.find((p) => p.index === table.superintendent)?.name ?? null) : null;
$('turnchart').innerHTML = turnChartHtml(f, actorName, superName); $('turnchart').innerHTML = turnChartHtml(
replaying ? { ...f, ...table, awaiting: null } : f,
actorName,
superName,
);
} }
/** /**
@@ -1000,6 +1080,7 @@ function beginRemote(ready: LobbyReady, rejoining = false): void {
// banner (`#presence`), and it holds a beat so the game visibly begins. // banner (`#presence`), and it holds a beat so the game visibly begins.
openHandoff(); openHandoff();
session = createRemoteSession(ready.token, ready.seat, abandonRemote); session = createRemoteSession(ready.token, ready.seat, abandonRemote);
remoteToken = ready.token;
rejoiningRemote = rejoining; rejoiningRemote = rejoining;
applyCapabilities(); applyCapabilities();
// A LocalSession has data the instant it is constructed; a RemoteSession does not — its first // A LocalSession has data the instant it is constructed; a RemoteSession does not — its first
@@ -1045,9 +1126,66 @@ function abandonRemote(): void {
* into a remembered multiplayer game, straight into solitaire (the zero-friction default, D11 — the * into a remembered multiplayer game, straight into solitaire (the zero-friction default, D11 — the
* common case and the only one a bare page load has ever needed a decision for), or the lobby. * common case and the only one a bare page load has ever needed a decision for), or the lobby.
*/ */
/**
* A SEAT RECOVERY LINK — Gitea#33.
*
* The token is the only identity this game has, and it lives in one browser's `localStorage`. Lose
* that and the seat is unreachable: nothing else on the server will accept a claim to it. This is the
* supported way back — an administrator mints a short-lived, single-use code (`server/claims.ts`) and
* the player opens a link carrying it.
*
* THE LINK CARRIES A CODE, NEVER THE TOKEN. `lobby-and-sessions.md` §1 says to keep the token out of
* URLs so it is not shoulder-surfed or pasted into a chat — and a recovery link is precisely the sort
* of thing that ends up in a chat. So the code is traded for the token here, over the connection the
* page was going to open anyway, and is dead the moment it is spent.
*
* THE CODE IS STRIPPED FROM THE URL EITHER WAY, so a reload does not re-spend a code that is already
* gone and the address bar stops carrying a credential-shaped string. `replaceState` rather than
* assigning `location.search`, which everywhere else on this page means "navigate" — it reloads, and
* reloading is exactly what must not happen to the session we have just been handed. Guarded like
* `requestAnimationFrame` and `performance` are, because the static build is imported head-first by
* `test/web.test.ts` against a DOM stub that provides neither.
*/
async function claimSeat(code: string): Promise<void> {
showScreen('lobby');
const { status, body } = await postJson('/api/claim', { code });
if (typeof history !== 'undefined' && typeof history.replaceState === 'function') {
history.replaceState(null, '', location.pathname);
}
if (status !== 200) {
runLobby(lobbyHandlers);
notice(
'That restore link has already been used, or it has expired. Ask whoever runs the server for a ' +
'fresh one — each link works once.',
);
return;
}
// `beginRemote` writes the seat into this browser's storage itself, which is the whole point of
// the exercise: the next ordinary reload finds it and goes straight back into the game.
beginRemote(
{
token: body['token'] as string,
gameId: body['gameId'] as string,
gameCode: (body['gameCode'] as string | undefined) ?? '',
seat: body['player'] as PlayerIndex,
},
true,
);
}
function start(): void { function start(): void {
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
/**
* A RECOVERY LINK OUTRANKS EVERYTHING, including a game this browser already remembers: someone
* arriving on one is being handed a seat deliberately, and that is never the load to second-guess.
*/
const claimCode = params.get('claim');
if (claimCode !== null && claimCode !== '') {
void claimSeat(claimCode);
return;
}
/** /**
* ASKING FOR THE LOBBY BEATS RESUMING A GAME. * ASKING FOR THE LOBBY BEATS RESUMING A GAME.
* *
@@ -1167,6 +1305,14 @@ function start(): void {
* the lobby's job. Hidden rather than disabled: a greyed-out Undo in a multiplayer game invites the * the lobby's job. Hidden rather than disabled: a greyed-out Undo in a multiplayer game invites the
* question "why not?" every turn, and the honest answer is that the control does not belong there. * question "why not?" every turn, and the honest answer is that the control does not belong there.
*/ */
/**
* The session token of a server-backed game, or null in solitaire (playtest, 2026-09-15: "most of the
* time, I want to go ahead and just save it as a JSON file"). It is the seat's proof of identity to
* `/api/save`, exactly as it is to `/api/stream` — a save is the seed and the moves, every one of which
* is already on this player's screen.
*/
let remoteToken: string | null = null;
function applyCapabilities(): void { function applyCapabilities(): void {
const c = session.capabilities; const c = session.capabilities;
const hide = (id: string, on: boolean): void => { const hide = (id: string, on: boolean): void => {
@@ -1174,7 +1320,7 @@ function applyCapabilities(): void {
if (el) el.hidden = !on; if (el) el.hidden = !on;
}; };
hide('undo', c.undo); hide('undo', c.undo);
hide('savefile', c.saveLocal); hide('savefile', c.saveLocal || remoteToken !== null);
hide('newgame', c.newGame); hide('newgame', c.newGame);
// Creating or joining ANOTHER multiplayer game from inside a running one is not a thing this // Creating or joining ANOTHER multiplayer game from inside a running one is not a thing this
// page offers — same reasoning as `newgame`, and the same capability answers both. // page offers — same reasoning as `newgame`, and the same capability answers both.
@@ -1347,8 +1493,10 @@ function render(): void {
// -- division // -- division
$('division').innerHTML = divisionSvg(f.division, { $('division').innerHTML = divisionSvg(f.division, {
players: f.players, players: f.players,
actor: f.actor, actor: actorOnScreen(stepQueue, f.actor).actor,
viewer: f.viewer, viewer: f.viewer,
// A Realignment changes the Division under everyone; flashed only while the step that did it is up.
flash: stepQueue.busy() ? stepQueue.flashing() : [],
}); });
renderSeatingChain(f); renderSeatingChain(f);
applyZoom($('division')); applyZoom($('division'));
@@ -1633,7 +1781,16 @@ function render(): void {
// -- log // -- log
const log = $('log'); const log = $('log');
const allLines = session.lines(); /**
* THE LOG IS HELD BACK WITH THE BOARD (playtest, 2026-09-15).
*
* A push carries its narration and its display steps together, so every line of a bot's turn was in
* this panel before the board had drawn a single move of it — the history ran ahead of the "N behind"
* counter it is meant to match. Those lines are the TAIL of the log, so exactly the ones belonging to
* steps still queued are withheld, and each appears as its step goes up.
*/
const heldBack = stepQueue.pendingLines();
const allLines = heldBack > 0 ? session.lines().slice(0, -heldBack) : session.lines();
const shownLines = allLines.slice(-60); const shownLines = allLines.slice(-60);
/** /**
* WHERE THE GAME BEGAN. In a multiplayer game the bots move the instant the host presses Start, so * WHERE THE GAME BEGAN. In a multiplayer game the bots move the instant the host presses Start, so
@@ -1771,17 +1928,34 @@ function renderYards(f: Frame): void {
} }
function renderDistrict(f: Frame): void { function renderDistrict(f: Frame): void {
const open = districtMode === 'auto' ? FOCUS_PHASES.has(f.phaseKey) : districtMode === 'open'; // The phase ON SCREEN, so the panel opens for the Local Operations being WATCHED rather than for
// one the game has already moved past — same rule as the turn chart, see `shownTable`.
const open =
districtMode === 'auto' ? FOCUS_PHASES.has(shownTable(f).phaseKey) : districtMode === 'open';
const sec = $('district'); const sec = $('district');
if (open) sec.classList.remove('folded'); if (open) sec.classList.remove('folded');
else sec.classList.add('folded'); else sec.classList.add('folded');
const cars = f.cells.reduce((n, c) => n + c.cars.length, 0); /**
* THE SUMMARY COUNTS THE BOARD ON SCREEN, WHICH IS NOT ALWAYS YOUR OWN.
*
* The panel has drawn somebody else's district since v0.8.0 — `watchedDistrict` follows whoever is
* acting — and the heading beside this line says whose it is. The counts were read from `f`, the
* viewer's own Frame, every time: so while a bot's turn played out, the header read "Bot 2's Office
* Area" over a board of Bot 2's cards, with a summary counting YOUR cards, facilities and trains
* (Jesse, playtest 2026-09-16 — "the hidden office summary line describes my district, not the one
* being shown"). One source for the drawing and the counting, so the two cannot disagree again.
*/
const watched = watchedDistrict(f);
const cells = watched?.cells ?? f.cells;
const facilityCount = watched ? watched.facilities.length : f.facilities.length;
const cars = cells.reduce((n, c) => n + c.cars.length, 0);
// Trains, not cards-with-a-crew: the Office is the one card that may hold more than one, and a // Trains, not cards-with-a-crew: the Office is the one card that may hold more than one, and a
// card-count silently read "1 crew on the board" with two trains standing at a busy Station. // card-count silently read "1 crew on the board" with two trains standing at a busy Station.
const crew = f.cells.reduce((n, c) => n + c.trains.length, 0); const crew = cells.reduce((n, c) => n + c.trains.length, 0);
$('districtsummary').textContent = $('districtsummary').textContent =
`${f.cells.length} cards · ${f.facilities.length} facilities · ${cars} cars standing` + `${cells.length} cards · ${facilityCount} facilities · ${cars} cars standing` +
(crew > 0 ? ` · ${crew} crew on the board` : ''); (crew > 0 ? ` · ${crew} crew on the board` : '');
/** /**
@@ -1813,6 +1987,35 @@ function renderDistrict(f: Frame): void {
render(); render();
}; };
} }
/**
* ONE BUTTON PER OPPONENT, and nothing at a table of one.
*
* Built with `createElement` and `textContent` rather than interpolated into `innerHTML`, because
* a player's NAME is whatever they typed in the lobby — the one string on this page that comes
* from another person, and so the one that must never be pasted into markup.
*/
const peek = $('districtpeek');
const others = f.players.filter((p) => p.index !== f.viewer);
peek.innerHTML = '';
peek.hidden = others.length === 0;
for (const p of others) {
const b = document.createElement('button');
b.type = 'button';
b.className = 'ghost';
b.textContent = p.name;
b.title =
`Look at ${p.name}'s Office Area. It is read-only, and it reverts as soon as the board next ` +
`redraws — press Pause first if you want to study it.`;
b.onclick = () => {
peekPlayer = p.index;
render();
};
peek.appendChild(b);
}
// SPENT. The look lasted the render it asked for; the next one follows the game again.
peekPlayer = null;
} }
/** /**
@@ -2181,9 +2384,32 @@ function renderActions(
const addable = menu.makeUp.cars.length; const addable = menu.makeUp.cars.length;
html += html +=
`<div class="grp"><h3>${esc(menu.makeUp.title)}</h3>` + `<div class="grp"><h3>${esc(menu.makeUp.title)}</h3>` +
/**
* WHAT IS STILL WANTED, which is not what the heading says.
*
* The heading names what the card CALLS FOR and goes on saying it unchanged as cars go on, so
* the one question a player has while clicking — what is left? — was the only thing on screen
* that had to be worked out by eye, against a consist drawn in the other column (Jesse,
* playtest 2026-09-16). `consistNeeds` counts by the same categories `acceptsCar` does, so it
* can never ask for a car the engine would then refuse.
*/
(menu.makeUp.needs !== null
? `<div class="makeup-needs">Still needs <b>${esc(menu.makeUp.needs)}</b></div>`
: `<div class="makeup-needs done">Its card's consist is complete — nothing further may be added.</div>`) +
`<div class="dim makeup-note">` + `<div class="dim makeup-note">` +
(addable > 0 (addable > 0
? `Click a car in the Division Yard below to add it — ${addable} kind${addable === 1 ? '' : 's'} it may take are highlighted there.` ? `Click a car in the Division Yard below to add it — the ${addable} kind${addable === 1 ? '' : 's'} it may take ` +
`${addable === 1 ? 'is' : 'are'} highlighted in amber there. ` +
/**
* WHY YOUR TURN ENDS AFTER ONE CAR — §7's round, said where the clicking happens.
*
* It was written down only in the turn chart's New Train chip tooltip: hovered once, early
* on, and never again. "Click a car" then reads as "build this train", so a player adds one
* and the turn moves on with no explanation (Jesse, playtest 2026-09-16).
*/
`<b>One car each:</b> you add a single car, then the round passes to the next player — ` +
`starting from the Superintendent and working left, coming round again until the train is ` +
`full or the Division Yard holds nothing it can take.`
: menu.makeUp.pass !== null : menu.makeUp.pass !== null
? 'The Division Yard is bare, so there is nothing to add. Send the train out as it stands.' ? 'The Division Yard is bare, so there is nothing to add. Send the train out as it stands.'
: 'Nothing in the Division Yard may join this train, and passing is not allowed while the yard holds cars.') + : 'Nothing in the Division Yard may join this train, and passing is not allowed while the yard holds cars.') +
@@ -2317,21 +2543,60 @@ function renderActions(
* hundred bytes, so a finished game can be emailed or dropped on the site's replay directory — * hundred bytes, so a finished game can be emailed or dropped on the site's replay directory —
* where a rendered page would have been megabytes. * where a rendered page would have been megabytes.
*/ */
function downloadSave(): void { function writeFile(name: string, data: string): void {
// The button this fires from is hidden by `applyCapabilities()` for any session that cannot save
// (`#savefile`), but nothing stops this function being called directly, so the guard is repeated
// here rather than only trusted to the DOM.
if (!isLocal(session)) return;
const data = JSON.stringify(session.save(), null, 1);
const blob = new Blob([data], { type: 'application/json' }); const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = `station-master-seed${session.seed()}-day${session.view().day}.json`; a.download = name;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
/**
* WHICH GAME, HOW FAR IN, AND WHEN — Jesse, playtest 2026-09-16.
*
* The name was `station-master-day1-stage5.json` for every server game at that point in every
* Stage, so two saves off the same table collided in the downloads folder and neither said which
* table it came from. The join code is the one thing a player already says out loud to identify a
* game, so it leads: `whistle-6945.day1.stage5.2026.09.16.json`.
*
* Lowercased because a filename is not a thing you shout, and dotted because that is the shape
* Jesse asked for. A solitaire game has no join code and falls back to its seed, which is the
* equivalent identity for a game nobody else is sitting at.
*/
function saveFileName(prefix: string, f: { day: number; stage: number }): string {
const d = new Date();
const pad = (n: number): string => String(n).padStart(2, '0');
const date = `${d.getFullYear()}.${pad(d.getMonth() + 1)}.${pad(d.getDate())}`;
return `${prefix}.day${f.day}.stage${f.stage}.${date}.json`;
}
async function downloadSave(): Promise<void> {
const f = session.view();
/**
* A SERVER-BACKED GAME HAS NO LOCAL SAVE TO HAND OVER, so it asks the server for its own — the seat's
* token is the gate (`/api/save`), the same one the stream and every intent already use. The StartOS
* Manage Game action cannot do this: an action result is text only, with no file member in the SDK.
*/
if (!isLocal(session)) {
if (remoteToken === null) return;
try {
const res = await fetch(`/api/save?token=${encodeURIComponent(remoteToken)}`);
if (!res.ok) return;
const body = (await res.json()) as { save: unknown };
const code = gameCode === '' ? 'station-master' : gameCode.toLowerCase();
writeFile(saveFileName(code, f), JSON.stringify(body.save, null, 1));
} catch {
// Offline, or the game has been ended under us: the button simply does nothing, which is the
// same thing every other server call on this page does when the server is not there.
}
return;
}
const solo = gameCode === '' ? `station-master-seed${session.seed()}` : gameCode.toLowerCase();
writeFile(saveFileName(solo, f), JSON.stringify(session.save(), null, 1));
}
function save(): void { function save(): void {
if (!isLocal(session)) return; if (!isLocal(session)) return;
try { try {
@@ -2367,7 +2632,7 @@ document.head.appendChild(pageStyle);
installTooltips(); installTooltips();
const saveBtn = document.getElementById('savefile'); const saveBtn = document.getElementById('savefile');
if (saveBtn) saveBtn.onclick = downloadSave; if (saveBtn) saveBtn.onclick = () => void downloadSave();
/** /**
* Forget the saved game and deal a fresh one. * Forget the saved game and deal a fresh one.
+12 -3
View File
@@ -777,9 +777,18 @@ h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:#8b94a3;ma
/* And the piles that are NOT targets step back while a discard is being aimed, so the three that /* And the piles that are NOT targets step back while a discard is being aimed, so the three that
are stand out from the Salvage Yard beside them. */ are stand out from the Salvage Yard beside them. */
.cardrow.aiming .handcard:not(.target){opacity:.4} .cardrow.aiming .handcard:not(.target){opacity:.4}
.stock .ld.addable,.stock .mt.addable{cursor:pointer;border-radius:3px;padding:0 4px; /* A CAR YOU MAY ADD IS AN ACTION, SO IT WEARS THE ACTION COLOUR — Jesse, playtest 2026-09-16: the
outline:1px solid #5aa9e6;background:rgba(90,169,230,.16)} highlight was "just a small bold and basically the same color as everything else", which is the
.stock .ld.addable:hover,.stock .mt.addable:hover{background:rgba(90,169,230,.34)} whole difficulty with making the yard chip the button. #c8912f is the border colour that the
action buttons themselves use, so a clickable car looks like every other thing inviting a click,
rather than like a number that happens to be outlined.
THE LOADED/EMPTY TEXT COLOURS ARE LEFT ALONE. Green and blue-grey are what say which of the two
numbers is which; the amber answers "may I click this", which is a different question, and
painting over the first to answer the second would cost real information. */
.stock .ld.addable,.stock .mt.addable{cursor:pointer;border-radius:3px;padding:0 4px;font-weight:700;
outline:2px solid #c8912f;background:rgba(200,145,47,.20);box-shadow:0 0 0 2px rgba(200,145,47,.16)}
.stock .ld.addable:hover,.stock .mt.addable:hover{background:rgba(200,145,47,.38)}
/* Twelve Stages across, so a Day is one glance. The current Stage is lit, Stages already gone are /* Twelve Stages across, so a Day is one glance. The current Stage is lit, Stages already gone are
dimmed, and a slot the die has just filled flashes once. */ dimmed, and a slot the die has just filled flashes once. */
.tt{display:flex;gap:3px;flex-wrap:wrap} .tt{display:flex;gap:3px;flex-wrap:wrap}
+18 -2
View File
@@ -124,7 +124,7 @@ section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
#watching-what{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} #watching-what{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.wbehind{font-variant-numeric:tabular-nums;font-weight:700;color:#c9cee0; .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,#watching-pause{font-size:11px;padding:2px 10px;border-color:#e0b060;color:#e0b060;flex:none}
#watching-who{color:#c9cee0;font-weight:700} #watching-who{color:#c9cee0;font-weight:700}
#presence:empty{display:none} #presence:empty{display:none}
/* division strip */ /* division strip */
@@ -184,6 +184,14 @@ button.cardact.discard{color:#d6b48a}
.scheduled{display:block;font-size:11.5px;margin:0 0 7px;padding:3px 9px;border-radius:5px; .scheduled{display:block;font-size:11.5px;margin:0 0 7px;padding:3px 9px;border-radius:5px;
background:rgba(40,140,60,.22);border:1px solid #2f6b47;color:#bfe8cd;font-weight:600} background:rgba(40,140,60,.22);border:1px solid #2f6b47;color:#bfe8cd;font-weight:600}
.makeup-note{font-size:11px;margin:0 0 5px} .makeup-note{font-size:11px;margin:0 0 5px}
/* WHAT THE TRAIN STILL WANTS. Deliberately NOT amber: amber means "you can click this" everywhere
else on this page, and this line is the REASON for clicking rather than a thing to click — the
cars in the Division Yard are. Brighter than the note beneath it, because it answers the question
the player actually has while looking at it. `done` goes green like `.scheduled`: a complete
consist is good news, not an instruction. */
.makeup-needs{font-size:12px;margin:0 0 5px;color:#e6e9ee}
.makeup-needs b{color:#f2e6cf}
.makeup-needs.done{color:#bfe8cd}
/* WHICH TRAIN AM I SWITCHING. A row of crews rather than a stacked list — they are alternatives, /* WHICH TRAIN AM I SWITCHING. A row of crews rather than a stacked list — they are alternatives,
and the chosen one is the crew whose squares the board is drawing, so it wears the same violet and the chosen one is the crew whose squares the board is drawing, so it wears the same violet
"you are here" the rest of the page uses. */ "you are here" the rest of the page uses. */
@@ -878,7 +886,7 @@ ul.blocked li{padding:2px 0}
the front door: `index.html`'s two doors are `play.html?lobby` and `play.html?solitaire`, so 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 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. --> 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."> <span class="zoom" title="How long another player's or a bot's move is held on screen before the next one. Your own moves are never delayed — only theirs. Starts at 1×, which holds a switching move for one second; the slowest setting, 20×, holds it for twenty. Off draws every move at once.">
<button id="paceslower" aria-label="Slower playback">−</button><span id="pacelabel">1×</span><button id="pacefaster" aria-label="Faster playback">+</button> <button id="paceslower" aria-label="Slower playback">−</button><span id="pacelabel">1×</span><button id="pacefaster" aria-label="Faster playback">+</button>
</span> </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>
@@ -919,6 +927,10 @@ ul.blocked li{padding:2px 0}
the other end of the row — Jesse, 2026-09-09: "the skip button should be on the far left, in 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." --> 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> <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>
<!-- PAUSE BESIDE SKIP, not instead of it: they are opposite answers to "that went past too fast".
Skip gives up the animation to reach the game; Pause holds the board on the step being shown
for as long as you want to look at it, and gives the step back the dwell it still had. -->
<button id="watching-pause" class="ghost" type="button" title="Hold the board on the move being shown. Nothing is lost and nothing is hurried — press again to carry on from the same step.">Pause</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>
</div> </div>
@@ -931,6 +943,10 @@ ul.blocked li{padding:2px 0}
<h2><span id="districtwho">Your Office Area</span> <h2><span id="districtwho">Your Office Area</span>
<span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span> <span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span>
<span id="districttoggle" class="seg" role="group" aria-label="When to show your Office Area"><button id="dm-auto" class="ghost" type="button" title="Open during Local Operations and Cargo — the phases that change the district — and folded otherwise.">Auto-hide</button><button id="dm-open" class="ghost" type="button" title="Keep the Office Area open in every phase.">Always show</button><button id="dm-closed" class="ghost" type="button" title="Keep the Office Area folded in every phase. The summary line stays, so it reads as folded rather than missing.">Always hide</button></span> <span id="districttoggle" class="seg" role="group" aria-label="When to show your Office Area"><button id="dm-auto" class="ghost" type="button" title="Open during Local Operations and Cargo — the phases that change the district — and folded otherwise.">Auto-hide</button><button id="dm-open" class="ghost" type="button" title="Keep the Office Area open in every phase.">Always show</button><button id="dm-closed" class="ghost" type="button" title="Keep the Office Area folded in every phase. The summary line stays, so it reads as folded rather than missing.">Always hide</button></span>
<!-- LOOK AT ANOTHER PLAYER'S OFFICE AREA. Filled by `renderDistrict` with one button per
opponent, and collapsed at a table of one. The look is read-only and lasts a single
render on purpose — see `peekPlayer` in `main.ts`. -->
<span id="districtpeek" class="seg" role="group" aria-label="Look at another player's Office Area"></span>
</h2> </h2>
<div id="districtsummary" class="dim"></div> <div id="districtsummary" class="dim"></div>
<!-- THE RULE THAT SHAPES EVERY DISTRICT, said once where the district is. <!-- THE RULE THAT SHAPES EVERY DISTRICT, said once where the district is.
+6 -1
View File
@@ -56,7 +56,12 @@ type Step = {
function rebuild(save: Save): { steps: Step[]; stoppedEarly: boolean } { function rebuild(save: Save): { steps: Step[]; stoppedEarly: boolean } {
const s = createGame({ id: `replay-${save.seed}`, seed: save.seed, config: SOLO_CONFIG, playerNames: ['player'] }); const s = createGame({ id: `replay-${save.seed}`, seed: save.seed, config: SOLO_CONFIG, playerNames: ['player'] });
const steps: Step[] = []; const steps: Step[] = [];
const ctx = { cardName: (id: string) => cardName(s, id), trainName: (id: string) => trainName(s, id) }; const ctx = {
cardName: (id: string) => cardName(s, id),
trainName: (id: string) => trainName(s, id),
// A player index is not a seat index, so the fallback names no number at all — see `game.ts`.
playerName: (p: number) => s.players[p]?.name ?? 'another player',
};
const push = (events: ReturnType<typeof pump>): void => { const push = (events: ReturnType<typeof pump>): void => {
const lines = events const lines = events
.filter((e) => e.type !== 'actorChanged') .filter((e) => e.type !== 'actorChanged')
+91 -1
View File
@@ -19,7 +19,7 @@
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, changedPiles } from '../sim/public-delta.ts'; import { applyPublicDelta, changedDivisionCards, changedPiles } from '../sim/public-delta.ts';
import type { PileKey } 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';
@@ -53,8 +53,62 @@ export type StepQueue = {
lit(): readonly PileKey[]; lit(): readonly PileKey[];
/** True while there is anything left to show. */ /** True while there is anything left to show. */
busy(): boolean; busy(): boolean;
/**
* How many narrated lines belong to steps NOT yet shown.
*
* The log and the board are two different moments while the queue is behind: a push carries its
* narration and its steps together, so every line of a bot's turn is in the history panel before the
* board has drawn a single move of it (playtest, 2026-09-15: *"is it possible to stall history so it
* stays in sync with the number behind?"*). Those lines are the TAIL of the log — they arrived last —
* so the caller holds back exactly this many and reveals each as its step goes up.
*/
pendingLines(): number;
/** Division nodes whose card changed in the step now on screen, for the map to flash. */
flashing(): readonly number[];
/**
* HOLD THE PLAYBACK ON THE STEP NOW SHOWING — Jesse, playtest 2026-09-16, asking for a pause
* beside Skip.
*
* Skip is the only control the row has had, and it is one-way and total: the way to look harder at
* a move that just went past was to not be too slow about it. Pause is the opposite lever — the
* board stops where it is and nothing is consumed, so a player can read the caption, look at the
* district and then carry on from exactly that step.
*
* TAKES `now` BECAUSE THE QUEUE OWNS NO CLOCK (see the note at the top of this file). The dwell
* still owing is preserved across the hold rather than being spent while nobody was watching:
* `resume` pushes the deadline out by however long the pause lasted, so a step paused with 200ms
* left resumes with 200ms left instead of vanishing on the next frame.
*
* Returns false when there is nothing to hold, or nothing being held.
*/
pause(now: number): boolean;
resume(now: number): boolean;
paused(): boolean;
}; };
/**
* WHOSE MOVE THE SCREEN IS SHOWING (Gitea#25).
*
* The game and the board on screen are two different moments. The server plays every bot move the
* instant a human's turn ends (`driveBots`), so the LIVE game is nearly always waiting on the human —
* while this queue is still replaying the bots, step by step. The turn chart and the Division map's
* move marker read the live actor, so a table of one person and three bots said "waiting on" that
* person throughout, against a playback row naming the bot actually moving.
*
* While the queue is behind or still showing a step, the answer is that step's player — `null` for an
* automatic phase, which is "the Division is running itself". Otherwise it is the live actor, and
* `replaying` is false so a caller can keep live-only detail, such as a ruling the game is waiting on,
* off a screen that has not caught up with it yet.
*/
export function actorOnScreen(
queue: Pick<StepQueue, 'behind' | 'busy' | 'showing'>,
live: number | null,
): { actor: number | null; replaying: boolean } {
if (queue.behind() === 0 && !queue.busy()) return { actor: live, replaying: false };
const shown = queue.showing();
return shown === null ? { actor: live, replaying: false } : { actor: shown.player, replaying: true };
}
/** /**
* `pace` is read on every step rather than captured, so changing the setting takes effect at once. * `pace` is read on every step rather than captured, so changing the setting takes effect at once.
* *
@@ -74,9 +128,12 @@ export function createStepQueue(
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 litPiles: readonly PileKey[] = [];
let flashedCards: readonly number[] = [];
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;
/** When the player pressed Pause, so `resume` can give the current step back the time it had. */
let pausedAt: number | null = null;
/** How long this step holds the screen — zero for the viewer's own moves; see above. */ /** How long this step holds the screen — zero for the viewer's own moves; see above. */
const dwell = (step: DisplayStep): number => const dwell = (step: DisplayStep): number =>
@@ -92,6 +149,9 @@ export function createStepQueue(
* that gives your own steps no dwell — the display is for watching everybody else. * that gives your own steps no dwell — the display is for watching everybody else.
*/ */
litPiles = step.player !== null && step.player === viewer() ? [] : changedPiles(before, shown); litPiles = step.player !== null && step.player === viewer() ? [] : changedPiles(before, shown);
// A Realignment changes the Division under everyone, so it is flashed for the player who did it
// too — unlike a pile, which only tells the drawer what they already know.
flashedCards = changedDivisionCards(before, shown);
}; };
return { return {
@@ -99,8 +159,12 @@ export function createStepQueue(
shown = frame; shown = frame;
pending = []; pending = [];
dueAt = null; dueAt = null;
// A reconnect, an undo or a fresh deal replaces the board outright; a hold on the playback that
// no longer exists would leave the row stuck reading "paused" with nothing behind it.
pausedAt = null;
// Nothing was watched arriving at this board, so nothing on it is lit. // Nothing was watched arriving at this board, so nothing on it is lit.
litPiles = []; litPiles = [];
flashedCards = [];
// `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.
}, },
@@ -110,6 +174,9 @@ export function createStepQueue(
}, },
advance(now) { advance(now) {
// Held. Nothing is shown and, crucially, nothing is CONSUMED — `dueAt` is left where it was
// and `resume` moves it, so the hold costs the current step none of its dwell.
if (pausedAt !== null) return false;
if (pending.length === 0) { if (pending.length === 0) {
// The LAST step of a burst still owes its dwell. Clearing `dueAt` here reported the queue // 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 // idle the instant that step was shown, which snapped the district panel home before anyone
@@ -143,6 +210,9 @@ export function createStepQueue(
}, },
skip() { skip() {
// Skipping while held is a decision to stop watching, so it also lifts the hold — otherwise the
// board would jump to the game and then sit there paused, with a Resume button that does nothing.
pausedAt = null;
if (pending.length === 0) return false; if (pending.length === 0) return false;
for (const step of pending) show(step); for (const step of pending) show(step);
pending = []; pending = [];
@@ -150,6 +220,24 @@ export function createStepQueue(
return true; return true;
}, },
pause(now) {
if (pausedAt !== null) return false;
// Nothing on screen owes any time and nothing is queued: there is no playback to hold.
if (pending.length === 0 && dueAt === null) return false;
pausedAt = now;
return true;
},
resume(now) {
if (pausedAt === null) return false;
// Give the step on screen back exactly the dwell it was holding when the player pressed Pause.
if (dueAt !== null) dueAt += now - pausedAt;
pausedAt = null;
return true;
},
paused: () => pausedAt !== null,
current: () => shown, current: () => shown,
behind: () => pending.filter((s) => dwell(s) > 0).length, behind: () => pending.filter((s) => dwell(s) > 0).length,
showing: () => last, showing: () => last,
@@ -165,6 +253,8 @@ export function createStepQueue(
* `dueAt` is non-null exactly while the step on screen has time left, so the two together mean * `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". * "there is more to come, or what is up has not had its moment yet".
*/ */
pendingLines: () => pending.reduce((n, s) => n + s.lines.length, 0),
flashing: () => flashedCards,
busy: () => pending.length > 0 || dueAt !== null, busy: () => pending.length > 0 || dueAt !== null,
}; };
} }
+72 -1
View File
@@ -1141,7 +1141,12 @@ describe('the history says WHY a train moved, and says it truthfully', () => {
// A train ahead of it in the same Subdivision, running the SAME way — §8.1's fourth condition, // A train ahead of it in the same Subdivision, running the SAME way — §8.1's fourth condition,
// which is the Superintendent's call rather than an absolute bar. // which is the Superintendent's call rather than an absolute bar.
const ahead = s.division.nodes.findIndex((n) => n.kind === 'mainline'); //
// AHEAD MEANS EAST OF THE OFFICE for this eastbound train. This used to take the FIRST Mainline card
// in the Division, which is west of the Office — behind the train — and still expected a ruling,
// which is exactly the fault Gitea#26 reported. The card is now one the train would actually follow.
const office = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
const ahead = s.division.nodes.findIndex((n, i) => i > office && n.kind === 'mainline');
const node = s.division.nodes[ahead]; const node = s.division.nodes[ahead];
assert.equal(node?.kind, 'mainline'); assert.equal(node?.kind, 'mainline');
s.trays.set('ahead', { s.trays.set('ahead', {
@@ -1511,3 +1516,69 @@ describe('an Extra starts where the player puts it (Gitea#4)', () => {
assert.equal(check(s, 0, at), 'NO_EXTRA_PENDING'); assert.equal(check(s, 0, at), 'NO_EXTRA_PENDING');
}); });
}); });
// ---------------------------------------------------------------------------
describe('§8.1 counts only trains AHEAD of the one departing (Gitea#26)', () => {
/**
* REPORTED from playtesting v0.8.0.9: two westbound Extras, X15 at an Office and X18 still crossing
* the card to its EAST. The Superintendent was asked to rule on X15 against X18 — a train behind it —
* and holding X15 kept the Whistle Post's only A/D track full, so X18 arrived into it and was
* destroyed. Reproduced by replaying the exported save; the positions below are that situation in a
* one-seat Division, where every Office is a Whistle Post and the Subdivision spans them all.
*/
const setup = (occupant: { direction: 'east' | 'west'; side: 'east' | 'west'; number: number }) => {
const s = game(7, { days: 5 });
const area = areaOf(s, 0);
const office = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
s.trays.set('departing', {
id: 'departing', trainNumber: 15, trainIsExtra: true, engineAt: 0, consist: [],
direction: 'west', facing: 'w',
position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
});
area.adOccupancy.push('departing');
const card = s.division.nodes.findIndex((n, i) =>
n.kind === 'mainline' && (occupant.side === 'east' ? i > office : i < office));
const node = s.division.nodes[card];
assert.equal(node?.kind, 'mainline');
s.trays.set('other', {
id: 'other', trainNumber: occupant.number, trainIsExtra: true, engineAt: 0, consist: [],
direction: occupant.direction, facing: occupant.direction === 'east' ? 'e' : 'w',
position: { at: 'mainline', index: card }, movesUsed: 0,
});
if (node?.kind === 'mainline') {
node.transits.push({ tray: 'other', stagesRemaining: 2, stagesTotal: 2, direction: occupant.direction });
}
s.clock.phase = 'mainline';
return s;
};
it('does not put a same-direction train BEHIND the departing one to the Superintendent', () => {
const s = setup({ direction: 'west', side: 'east', number: 18 });
const r = advance(s);
assert.ok(!r.events.some((e) => e.type === 'clearanceRequested'), 'a train behind was put to the Superintendent');
assert.ok(
r.events.some((e) => e.type === 'trainHighballed' && e.trainNumber === 15),
'the departing train did not highball with nothing ahead of it',
);
assert.ok(!r.events.some((e) => e.type === 'trainsDestroyed'), 'a train was destroyed');
});
it('does not bar a departure over an opposite-direction train BEHIND it, which is moving away', () => {
const s = setup({ direction: 'east', side: 'east', number: 18 });
const r = advance(s);
assert.ok(
r.events.some((e) => e.type === 'trainHighballed' && e.trainNumber === 15),
'a train moving away behind it held the departure',
);
});
it('still puts a same-direction train AHEAD to the Superintendent', () => {
const s = setup({ direction: 'west', side: 'west', number: 18 });
const r = advance(s);
assert.ok(
r.events.some((e) => e.type === 'clearanceRequested' && e.trainId === 'departing'),
'a train the departing one would follow was not put to the Superintendent',
);
});
});
+38
View File
@@ -254,3 +254,41 @@ describe('which piles a step moved', () => {
assert.deepEqual(changedPiles(null, publicSnapshot(s)), []); assert.deepEqual(changedPiles(null, publicSnapshot(s)), []);
}); });
}); });
describe('a Mainline card that changed under the players (Gitea#28)', () => {
it('names the node a Realignment converted, and nothing else', async () => {
const { changedDivisionCards } = await import('../src/sim/public-delta.ts');
const { REALIGNMENTS } = await import('../src/engine/content.ts');
const s = newState(4242);
const before = publicSnapshot(s);
// Realignment converts a card to another kind (`content.ts`'s table). Applied to the state directly:
// what is being tested is the DETECTOR, not the play that reaches it — which needs the card in hand,
// the draw option taken and no train on the card.
const at = s.division.nodes.findIndex((n) => n.kind === 'mainline' && REALIGNMENTS.some((r) => r.from === n.card));
assert.ok(at >= 0, 'no Mainline card in this Division can be realigned at all');
const node = s.division.nodes[at];
assert.equal(node?.kind, 'mainline');
if (node?.kind === 'mainline') {
node.card = REALIGNMENTS.find((r) => r.from === node.card)!.to;
}
const after = publicSnapshot(s);
assert.deepEqual(changedDivisionCards(before, after), [at], 'the realigned card was not the one reported');
assert.deepEqual(changedDivisionCards(after, after), [], 'an unchanged Division reported a change');
assert.deepEqual(changedDivisionCards(null, after), [], 'a first board has nothing to compare against');
});
it('says nothing when only the trains on a card moved', async () => {
const { changedDivisionCards } = await import('../src/sim/public-delta.ts');
const s = newState(1917398);
const before = publicSnapshot(s);
const at = s.division.nodes.findIndex((n) => n.kind === 'mainline');
const node = s.division.nodes[at];
if (node?.kind === 'mainline') {
node.transits.push({ tray: 'tray1', stagesRemaining: 1, stagesTotal: 1, direction: 'east' });
}
assert.deepEqual(changedDivisionCards(before, publicSnapshot(s)), [], 'a train arriving flashed the card');
});
});
+27
View File
@@ -146,6 +146,33 @@ describe('narration', () => {
assert.match(loss.text, /COLLISION/i); assert.match(loss.text, /COLLISION/i);
}); });
/**
* WHOSE OFFICE, AND WHOSE TRAIN — Jesse, playtest 2026-09-16.
*
* The line went to every seat reading "ARRIVED at the Whistle Post … You can work it in Cargo now".
* At a table of four that is one true sentence and one false one: every seat has an Office, so the
* tier alone does not say which district the train is standing in, and three of the four readers
* cannot touch it. Constructed here rather than fished out of a game so both halves are pinned
* exactly, and the resolver-less path is checked too — the replay viewers pass no names for a
* table they do not have.
*/
it('names WHOSE Office a train reached, and never tells the table they can work it', () => {
const named = narrate(
{ type: 'trainArrived', trainNumber: 3, consist: [], office: 'Whistle Post', owner: 1, expedited: false },
{ playerName: () => 'Tom' },
);
assert.match(named.text, /Tom's Whistle Post/, `the arrival did not name the Office's owner: ${named.text}`);
assert.match(named.text, /Tom can work it/, `the arrival did not say whose train it is to work: ${named.text}`);
assert.doesNotMatch(named.text, /\bYou can work it\b/i, `the arrival still addresses every reader: ${named.text}`);
// No resolver — still English, and still no raw index leaking into a sentence.
const anon = narrate({
type: 'trainArrived', trainNumber: 3, consist: [], office: 'Whistle Post', owner: 1, expedited: false,
});
assert.match(anon.text, /at the Whistle Post/, anon.text);
assert.doesNotMatch(anon.text, /undefined|\bplayer \d+\b/i, anon.text);
});
it('points at the board cell where something happened', () => { it('points at the board cell where something happened', () => {
const n = narrate({ type: 'loadCompleted', player: 0, at: { row: 1, col: 2 }, carType: 'hopper' }); const n = narrate({ type: 'loadCompleted', player: 0, at: { row: 1, col: 2 }, carType: 'hopper' });
assert.deepEqual(n.where, { row: 1, col: 2 }); assert.deepEqual(n.where, { row: 1, col: 2 });
+68
View File
@@ -0,0 +1,68 @@
/**
* Seat recovery codes — Gitea#33.
*
* The properties worth pinning are the ones that make a code safe to put in a link: it is spendable
* exactly once, it stops working on its own, and a bad code is indistinguishable from a spent one.
* `now` is a parameter rather than a clock, so expiry is tested without faking timers.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { CLAIM_TTL_MS, createClaimStore } from '../../src/server/claims.ts';
describe('seat recovery codes', () => {
it('mints a code that names the seat it was minted for', () => {
const claims = createClaimStore();
const { code, expiresAt } = claims.mint('tok-abc', 'game-1', 1000);
assert.equal(expiresAt, 1000 + CLAIM_TTL_MS);
assert.deepEqual(claims.redeem(code, 1000), { token: 'tok-abc', gameId: 'game-1' });
});
it('spends a code exactly once — a link in a chat log is worth nothing afterwards', () => {
const claims = createClaimStore();
const { code } = claims.mint('tok-abc', 'game-1', 0);
assert.ok(claims.redeem(code, 1));
assert.equal(claims.redeem(code, 2), null, 'the same code was accepted twice');
});
it('stops working once its time is up, without anything having to sweep it', () => {
const claims = createClaimStore();
const { code } = claims.mint('tok-abc', 'game-1', 0);
assert.equal(claims.redeem(code, CLAIM_TTL_MS - 1)?.token, 'tok-abc', 'expired early');
const again = claims.mint('tok-abc', 'game-1', 0).code;
assert.equal(claims.redeem(again, CLAIM_TTL_MS), null, 'a code outlived its expiry');
});
it('answers the same way for unknown, spent and expired codes', () => {
const claims = createClaimStore();
const { code } = claims.mint('tok-abc', 'game-1', 0);
claims.redeem(code, 1);
const expired = claims.mint('tok-abc', 'game-1', 0).code;
assert.equal(claims.redeem('never-existed', 1), null);
assert.equal(claims.redeem(code, 1), null);
assert.equal(claims.redeem(expired, CLAIM_TTL_MS + 1), null);
});
it('gives every mint its own code', () => {
const claims = createClaimStore();
const codes = new Set([0, 1, 2, 3, 4].map(() => claims.mint('tok-abc', 'game-1', 0).code));
assert.equal(codes.size, 5, 'two mints produced the same code');
});
it('forgets expired codes rather than accumulating them', () => {
const claims = createClaimStore();
claims.mint('tok-a', 'game-1', 0);
claims.mint('tok-b', 'game-1', 0);
assert.equal(claims.outstanding(0), 2);
assert.equal(claims.outstanding(CLAIM_TTL_MS), 0, 'expired codes were still being held');
});
it('keeps a short-lived code short-lived when asked for one', () => {
const claims = createClaimStore();
const { code, expiresAt } = claims.mint('tok-abc', 'game-1', 500, 60_000);
assert.equal(expiresAt, 60_500);
assert.equal(claims.redeem(code, 60_500), null);
});
});
+130 -1
View File
@@ -15,7 +15,7 @@ import { currentActor, newMultiplayerGame, submit } from '../src/web/game.ts';
import { publicSnapshot } from '../src/sim/view.ts'; import { publicSnapshot } from '../src/sim/view.ts';
import { takeSteps } from '../src/sim/display-step.ts'; import { takeSteps } from '../src/sim/display-step.ts';
import type { DisplayStep } from '../src/sim/display-step.ts'; import type { DisplayStep } from '../src/sim/display-step.ts';
import { createStepQueue } from '../src/web/step-queue.ts'; import { actorOnScreen, createStepQueue } from '../src/web/step-queue.ts';
import { DWELL } from '../src/sim/pacing.ts'; import { DWELL } from '../src/sim/pacing.ts';
const config: GameConfig = { const config: GameConfig = {
@@ -109,6 +109,61 @@ describe('the step queue', () => {
assert.equal(q2.behind(), 4, 'and must be replaced once its dwell is up'); assert.equal(q2.behind(), 4, 'and must be replaced once its dwell is up');
}); });
/**
* PAUSE — Jesse, playtest 2026-09-16, asking for one beside Skip.
*
* The property that matters is not "it stops", which any flag gives you. It is that a hold COSTS
* THE STEP NOTHING: a move paused half-way through its dwell has to resume with half a dwell left,
* or pausing to look at something would punish you by throwing the rest of it away the moment you
* let go. That is the whole assertion below, measured against `DWELL.switching` rather than a
* hand-picked number so it follows the tuning table.
*/
it('pause holds the board where it is, and resume gives the step back the dwell it had left', () => {
const { steps } = realSteps(1917398, 400);
const switching = steps.filter((s) => s.cause.startsWith('switch.') && s.cause !== 'switch.end');
assert.ok(switching.length >= 6, `only ${switching.length} switching steps found`);
const q = createStepQueue();
q.reset(baseline(1917398));
q.push(switching.slice(0, 6));
q.advance(0);
assert.equal(q.behind(), 5, 'the first is shown at once; five are still to watch');
// Held half-way through the first move's dwell, and left held for ten times that long.
const half = DWELL.switching / 2;
assert.equal(q.pause(half), true);
assert.equal(q.paused(), true);
const held = q.showing()?.seq;
q.advance(half + 10_000);
assert.equal(q.behind(), 5, 'a held queue consumed a step');
assert.equal(q.showing()?.seq, held, 'the board moved while it was supposed to be held');
assert.equal(q.busy(), true, 'a held queue must still read busy, or the render loop stops');
// Resumed, the move still owes exactly the half-dwell it had left — no more, and no less.
const at = half + 10_000;
assert.equal(q.resume(at), true);
assert.equal(q.paused(), false);
q.advance(at + half - 1);
assert.equal(q.behind(), 5, 'the step was robbed of time it was owed while held');
q.advance(at + half);
assert.equal(q.behind(), 4, 'and it never gave way once the rest of its dwell was up');
});
it('refuses to hold an idle queue, and Skip lifts a hold rather than leaving it stuck', () => {
const q = createStepQueue();
q.reset(baseline(1917398));
assert.equal(q.pause(0), false, 'an idle queue has no playback to hold');
assert.equal(q.paused(), false);
const { steps } = realSteps(1917398, 400);
q.push(steps);
q.advance(0);
assert.equal(q.pause(10), true);
assert.equal(q.skip(), true, 'Skip must still work while held');
assert.equal(q.paused(), false, 'Skip left the queue held, with a Resume that does nothing');
assert.equal(q.busy(), false);
});
it('counts only what will be watched, so the countdown is steady', () => { it('counts only what will be watched, so the countdown is steady', () => {
// The counter's whole purpose: a backlog of mostly-bookkeeping must not read as a huge number // The counter's whole purpose: a backlog of mostly-bookkeeping must not read as a huge number
// that collapses the instant it starts. // that collapses the instant it starts.
@@ -278,3 +333,77 @@ describe('the step queue', () => {
assert.equal(q.showing(), null); assert.equal(q.showing(), null);
}); });
}); });
describe('whose move the screen is showing (Gitea#25)', () => {
const step = (player: number | null) => ({ player }) as DisplayStep;
const queue = (behind: number, busy: boolean, showing: DisplayStep | null) => ({
behind: () => behind,
busy: () => busy,
showing: () => showing,
});
it('names the live actor once the board has caught up', () => {
assert.deepEqual(actorOnScreen(queue(0, false, step(2)), 0), { actor: 0, replaying: false });
});
it('names the player of the step on screen while the board is behind — not the live actor', () => {
// One human (seat 0) against bots: the live game already waits on seat 0 while bot 2's moves replay.
assert.deepEqual(actorOnScreen(queue(3, true, step(2)), 0), { actor: 2, replaying: true });
});
it('keeps naming the last step while it is still on screen, after the counter reaches zero', () => {
assert.deepEqual(actorOnScreen(queue(0, true, step(1)), 0), { actor: 1, replaying: true });
});
it('names nobody for an automatic phase being shown', () => {
assert.deepEqual(actorOnScreen(queue(2, true, step(null)), 0), { actor: null, replaying: true });
});
it('falls back to the live actor before any step has been shown', () => {
assert.deepEqual(actorOnScreen(queue(1, true, null), 3), { actor: 3, replaying: false });
});
});
describe('the log is held back with the board, and a changed card flashes (playtest, 2026-09-15)', () => {
it('owes exactly the lines of the steps not yet shown', () => {
const { steps } = realSteps(1917398, 400);
const q = createStepQueue();
q.reset(baseline(1917398));
assert.equal(q.pendingLines(), 0, 'an empty queue holds nothing back');
q.push(steps);
const owed = steps.reduce((n, s) => n + s.lines.length, 0);
assert.equal(q.pendingLines(), owed, 'every queued step still owes its lines');
// Drive the clock as a render loop would; the debt falls monotonically and ends at nothing.
let now = 0;
let last = owed;
for (let i = 0; i < 20_000 && q.busy(); i++) {
q.advance(now);
const left = q.pendingLines();
assert.ok(left <= last, 'the held-back count grew while the board caught up');
last = left;
now += 50;
}
assert.equal(q.pendingLines(), 0, 'the board caught up but lines were still withheld');
});
it('skipping reveals the whole log at once', () => {
const { steps } = realSteps(1917398, 400);
const q = createStepQueue();
q.reset(baseline(1917398));
q.push(steps);
q.skip();
assert.equal(q.pendingLines(), 0, 'Skip left lines withheld — the history would stay short');
});
it('flashes nothing on an ordinary step', () => {
// Realignment is rare in bot play, so this pins the quiet case: the map must not pulse at random.
const { steps } = realSteps(1917398, 400);
const q = createStepQueue();
q.reset(baseline(1917398));
q.push(steps.slice(0, 5));
q.advance(0);
assert.deepEqual(q.flashing(), [], 'a step that changed no Mainline card flashed one');
});
});
+9 -5
View File
@@ -151,10 +151,14 @@ describe('the tally counts every event exactly once (Gitea#16)', () => {
it('splits Revenue into what was earned and what was given back', () => { it('splits Revenue into what was earned and what was given back', () => {
// Reconciliation is the real assertion and it holds for any game, earned or not: gained minus // Reconciliation is the real assertion and it holds for any game, earned or not: gained minus
// lost IS the score the engine kept. Seed 42 is named because it is one where Revenue actually // lost IS the score the engine kept. Seed 44 is named because it is one where Revenue actually
// moves in both directions — it earns 1 and gives back 5 to a collision — so the two halves are // moves in both directions — it earns 1 and gives back 5 to a collision — so the two halves are
// being told apart rather than both sitting at zero. // being told apart rather than both sitting at zero.
for (const seed of [1, 7, 42]) { //
// It was seed 42 until v0.8.0.10. That game's collision was the Superintendent holding a train over
// one BEHIND it (Gitea#26); with the ruling gone the collision is too, and seed 42 now earns 5 and
// loses nothing — a better game and a vacuous test. The seed moved, not the assertion.
for (const seed of [1, 7, 44]) {
const { state } = playKeepingEvents(seed); const { state } = playKeepingEvents(seed);
const me = state.tally.byPlayer[0]!; const me = state.tally.byPlayer[0]!;
assert.equal( assert.equal(
@@ -163,10 +167,10 @@ describe('the tally counts every event exactly once (Gitea#16)', () => {
`seed ${seed}: gained minus lost does not reconcile with the score the engine kept`, `seed ${seed}: gained minus lost does not reconcile with the score the engine kept`,
); );
} }
const { state } = playKeepingEvents(42); const { state } = playKeepingEvents(44);
const me = state.tally.byPlayer[0]!; const me = state.tally.byPlayer[0]!;
assert.ok(me.revenueGained > 0, 'seed 42 earned nothing — the gained half is not being counted'); assert.ok(me.revenueGained > 0, 'seed 44 earned nothing — the gained half is not being counted');
assert.ok(me.revenueLost > 0, 'seed 42 lost nothing — the lost half is not being counted'); assert.ok(me.revenueLost > 0, 'seed 44 lost nothing — the lost half is not being counted');
}); });
it('records a Circus set-up as the one-off it is, not as a streak', () => { it('records a Circus set-up as the one-off it is, not as a streak', () => {
+68
View File
@@ -309,3 +309,71 @@ describe('steps reach a seated player — TODO #13', () => {
assert.ok(seen > 0, 'no steps reached a push, so this proved nothing'); assert.ok(seen > 0, 'no steps reached a push, so this proved nothing');
}); });
}); });
describe('the log says who acted, once, and in what capacity (Gitea#30, #31)', () => {
it('never names a player twice — no "Player Jesse player 0 finished …"', async () => {
const { newMultiplayerGame, currentActor, submit } = await import('../src/web/game.ts');
const { legalActions } = await import('../src/engine/legal.ts');
const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']);
for (let i = 0; i < 400; i++) {
const actor = currentActor(game);
if (actor === null) break;
const options = legalActions(game.state, actor);
if (options.length === 0) break;
if (!submit(game, options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
}
assert.ok(game.log.length > 50, 'the game barely ran, so this proved little');
for (const line of game.log) {
// `record()` prefixes the acting player's NAME; a narration that also named them read
// "Player Alice player 0 finished Local Operations" (playtest, 2026-09-15).
assert.doesNotMatch(
line.text,
/\bplayer \d+\b/i,
`a line still carries a bare player index: ${line.text}`,
);
}
});
it('attributes a clearance ruling to the office, not to the seat\'s own turn', async () => {
const { newMultiplayerGame, drain, submit } = await import('../src/web/game.ts');
const { areaOf } = await import('../src/engine/apply.ts');
const game = newMultiplayerGame(7, config, ['Alice', 'Bob', 'Carol']);
const s = game.state;
const area = areaOf(s, 0);
// A westbound train at seat 0's Office, and another westbound AHEAD of it — west of the Office —
// which is §8.1's fourth condition and the Superintendent's to rule on (see Gitea#26).
s.trays.set('departing', {
id: 'departing', trainNumber: 15, trainIsExtra: true, engineAt: 0, consist: [],
direction: 'west', facing: 'w',
position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
} as never);
area.adOccupancy.push('departing');
const office = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
const card = s.division.nodes.findIndex((n, i) => i < office && n.kind === 'mainline');
const node = s.division.nodes[card];
assert.equal(node?.kind, 'mainline');
s.trays.set('ahead', {
id: 'ahead', trainNumber: 9, trainIsExtra: false, engineAt: 0, consist: [],
direction: 'west', facing: 'w', position: { at: 'mainline', index: card }, movesUsed: 0,
} as never);
if (node?.kind === 'mainline') {
node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
}
s.clock.phase = 'mainline';
drain(game);
assert.equal(s.clock.pendingDecision?.kind, 'clearance', 'no ruling was called for, so nothing was tested');
const before = game.log.length;
assert.ok(submit(game, { type: 'mainline.clearance', allow: false }, s.clock.superintendent));
const said = game.log.slice(before).map((l) => l.text);
assert.ok(
said.some((text) => text.startsWith('Superintendent Player ')),
`a ruling did not read as the office's: ${said.join(' | ')}`,
);
});
});
+48 -1
View File
@@ -2641,8 +2641,16 @@ describe('the static build', () => {
*/ */
const base = { day: 2, stage: 5, clock: '2:20', phase: 'Mainline', phaseKey: 'mainline' }; const base = { day: 2, stage: 5, clock: '2:20', phase: 'Mainline', phaseKey: 'mainline' };
/**
* AND AN AUTOMATIC PHASE NAMES THE WORK, NOT AN ABSENCE (playtest, 2026-09-16). It used to read
* "waiting on nobody — the Division is running itself": an answer by negation, on a line whose
* whole job is to say where the game is. `base` is the Mainline Phase, so this is the case Jesse
* described — the Division moving trains with nobody to wait for.
*/
const idle = turnChartHtml({ ...base, actor: null, awaiting: null }, null, 'Bob'); const idle = turnChartHtml({ ...base, actor: null, awaiting: null }, null, 'Bob');
assert.match(idle, /nobody — the Division is running itself/, 'an automatic phase should say so'); assert.match(idle, /the Division is moving trains/, 'an automatic phase should say what it is doing');
assert.doesNotMatch(idle, /waiting on/, 'nobody is being waited on, so the line must not claim it');
assert.doesNotMatch(idle, /nobody/, 'the line still answers by negation');
for (const [asks, train] of [ for (const [asks, train] of [
['a clearance ruling', 'Train 4'], ['a clearance ruling', 'Train 4'],
@@ -2767,6 +2775,21 @@ describe('the static build', () => {
assert.ok(fallback !== '', 'the no-git fallback moved and this test cannot see it any more'); assert.ok(fallback !== '', 'the no-git fallback moved and this test cannot see it any more');
assert.doesNotMatch(fallback, /^'nogit'$|^"nogit"$/, 'the no-git fallback is a constant again'); assert.doesNotMatch(fallback, /^'nogit'$|^"nogit"$/, 'the no-git fallback is a constant again');
assert.match(fallback, /Date\.now\(\)/, 'the no-git fallback carries nothing that varies per build'); assert.match(fallback, /Date\.now\(\)/, 'the no-git fallback carries nothing that varies per build');
/**
* AND IT MUST NOT LEAD WITH THE VERSION — which is what fixing the constant first reached for.
*
* The stamp is `v${pkg.version} · ${git} · ${when}Z`, so a fallback of `${pkg.version}-${…}`
* spends the version twice, and does it on precisely the builds that take this path: every
* `.s9pk`, because the Dockerfile copies the tree in without `.git`. Reported from play —
* Jesse, 2026-09-16: *"the version number is in the header twice."* The uniqueness this test
* exists to defend comes from the timestamp, not from the version, so the two requirements do
* not compete.
*/
assert.doesNotMatch(
fallback,
/pkg\.version/,
'the no-git fallback repeats the version the stamp already prints in front of it',
);
}); });
it('lets a build-tagged URL be cached and nothing else', () => { it('lets a build-tagged URL be cached and nothing else', () => {
@@ -4205,6 +4228,30 @@ describe('the lobby screen', () => {
const chosen = (groups: Record<string, { value: string; checked: boolean }[]>, name: string): string | undefined => const chosen = (groups: Record<string, { value: string; checked: boolean }[]>, name: string): string | undefined =>
groups[name]!.find((r) => r.checked)?.value; groups[name]!.find((r) => r.checked)?.value;
/**
* A SEAT RECOVERY LINK — Gitea#33.
*
* The properties that make this safe to hand round are the ones worth pinning: the page trades the
* CODE for the token (so no token is ever in a URL), and it does not keep the code afterwards. The
* store's own single-use and expiry rules are proven in `test/server/claims.test.ts`; this is the
* client half, which is the part that could silently stop asking.
*/
it('trades a ?claim= code for a seat, and does not leave the code in the address bar', async () => {
const { sent } = await open('?claim=code-123', {
'/api/claim': { token: 'tok-restored', gameId: 'game-9', player: 1, gameCode: 'WHISTLE-6945' },
});
const claim = sent.find((r) => r.url.includes('/api/claim'));
assert.ok(claim, 'the page never redeemed the code');
assert.deepEqual(claim.body, { code: 'code-123' }, 'the code was not sent as the request body');
// The token must never travel in a URL (`lobby-and-sessions.md` §1) — it comes back in the
// response, and the only thing that went out was the one-time code.
assert.ok(
!sent.some((r) => r.url.includes('tok-restored')),
'a session token appeared in a request URL',
);
});
it('opens on the join door, with the create form behind it', async () => { it('opens on the join door, with the create form behind it', async () => {
// Somebody who was handed a code used to have to scroll past the entire create form to find the // Somebody who was handed a code used to have to scroll past the entire create form to find the
// box to type it into. // box to type it into.