Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6657241de | ||
|
|
ad277fb994 | ||
|
|
7c9ef8797d | ||
|
|
9a9e50b3c6 |
+446
@@ -19,6 +19,452 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
||||
|
||||
---
|
||||
|
||||
## 0.8.0.14 — 2026-09-17
|
||||
|
||||
Six reports from the Day 2-3 playtest of v0.8.0.13. Two were the same shortage seen from opposite
|
||||
ends, one was a rule working as designed that Jesse has now reversed, and three were the screen
|
||||
saying too little or too much.
|
||||
|
||||
**THIS RELEASE STRANDS GAMES IN PROGRESS, INCLUDING THE ONE ON THE BOX.** Modifiers are now bounded
|
||||
by the Limits, which makes a once-legal move illegal — so a save holding one is refused at that move.
|
||||
Measured on the playtest save: `whistle-6945.day3.stage10` stops at intent 528 of 539, eleven moves
|
||||
from the end, on `card.play c55 → (-2,4)`. The file is untouched and v0.8.0.13 still finishes it.
|
||||
|
||||
### Every coach in the game was stuck in the Classification Yard
|
||||
|
||||
Two reports, one cause. The Sparrow (trains 5/6) calls for three coaches and nothing else, and ran
|
||||
the length of the Division empty; Tom had a loaded coach standing at his Depot and could find no way
|
||||
to unload the passengers. Both are §2.2 and §9.2 acting exactly as printed, and the combination is a
|
||||
one-way ratchet: boarding discards the emptied coach into the **Classification** Yard, detraining
|
||||
draws a fresh empty **out of the Division Yard**, and Classification comes back only when the
|
||||
Division Yard runs bare.
|
||||
|
||||
Measured across the save, because the shape of it is the point: sixteen coaches in the Division Yard
|
||||
at setup, **zero from Day 2 Stage 8 onward**, fifteen piled up in Classification — while the Division
|
||||
Yard sat at 46-47 freight cars and stopped draining, so the refill was never going to fire. Every
|
||||
passenger operation in the game was over, and four of the twelve timetabled trains carry nothing but
|
||||
coaches.
|
||||
|
||||
**Jesse's ruling: the rule stands, the game says so loudly.** The same ruling Gitea#2 got, and the
|
||||
same one 0.8.0.13 gave the blocked Depot panel. Three places say it now:
|
||||
|
||||
- **A train made up short reports it** (`makeUpShort`). `trainNeedingCars` returns null both when a
|
||||
consist is full and when the yard holds nothing it will take — the same answer for "done" and
|
||||
"cannot be done" — so the phase moved on in silence, and the only trace was a MADE UP line
|
||||
promising "now taking cars" with nothing after it. It now names what the card wanted, how many are
|
||||
waiting in Classification, and how far the Division Yard is from bare.
|
||||
- **The yard panel warns while the condition lasts**, rather than only at the moment a train goes
|
||||
short.
|
||||
- **The Depot's blocked panel already said it**, and still does — it was correct all along, off to
|
||||
the side, and read by nobody who had gone looking in the wrong place.
|
||||
|
||||
### A modifier may no longer stand outside the Limits
|
||||
|
||||
Reported as a bug and it was working as designed — the design being a call of Jesse's, recorded in
|
||||
`apply.ts`: a Modifier is not track, so a Facility standing at the limit kept all nine of its spots,
|
||||
because bounding the card looked like it would make it unplayable exactly where a district ends.
|
||||
**Reversed.** What decided it is what the board shows — Transmission Lines at (-2,4) with the sign at
|
||||
column 3 reads as building outside your own territory, and §8.1 and §10 both reason about what lies
|
||||
inside a player's Limits.
|
||||
|
||||
The feared case was checked on the move that prompted the change rather than argued away: the Power
|
||||
Plant was at (-1,3) against a sign at column 3, and (-2,2) and (-2,3) were both free, legal and
|
||||
inside. Six of the nine spots survive a Facility at the limit, and the sign moves outward as the
|
||||
Running Track grows (§2.1, Gap 4a).
|
||||
|
||||
### The history panel was full of coordinates
|
||||
|
||||
"Does switching show up in history at all?" — it all did. A six-Move turn wrote a line per move,
|
||||
every one of them a pair of grid coordinates, plus one per mandatory coupling. **Switching is still
|
||||
logged in full**; what changed is what the panel DRAWS: the line saying somebody switched, the first
|
||||
move of the turn, work at an **industry**, the Small Yard sort, and a closing summary.
|
||||
|
||||
**The suppressed lines are still written, marked `trace`, and that is not a detail.** Dropping these
|
||||
events was the first attempt and the step-queue suite caught it: `dwellForStep` gives a step no dwell
|
||||
when it produced no narration, so a switching move with no line became a silent step and the board
|
||||
stopped replaying switching altogether — the exact thing v0.8.0 was built to let a table watch. The
|
||||
tone is the seam. The caption keeps its text, the panel filters the tone.
|
||||
|
||||
**The last move rides in the closing line rather than being kept in place**, and it has to: nothing
|
||||
knows a move was the last until the turn is over, and by then the line has been written and streamed
|
||||
to every client (`server/session.ts` § `linesSince`), so it cannot be revised. `switchingEnded` says
|
||||
what the turn cost and where the crew was left — "finished switching — 3 of 6 Moves used, leaving
|
||||
Train 10 at the Small Yard".
|
||||
|
||||
Places are named rather than pointed at: `at the Freight House` and `the Small Yard` instead of
|
||||
`(1,1)`. Two smaller things fell out of reading these lines properly. Every move line ended with
|
||||
"The crew chip on the grid carries the whole train with it" — six times a turn, when the opener
|
||||
already says it once, and it pushed the useful half of the line out of the caption row. And the move
|
||||
count said "of 6" with the 6 hardcoded, which is simply wrong on a Reduced-Visibility night Stage
|
||||
where a turn gets five; the turn now carries its own allowance.
|
||||
|
||||
### Making up a train says which train
|
||||
|
||||
"I did not see anything in the history about making up train 10." It was all there — a MADE UP line
|
||||
and one line per car — and every one of those lines read "the train being made up", so a player
|
||||
looking back for train 10 found nothing under that name. `carPlacedOnTrain` and `carPassed` now carry
|
||||
the number, the way `trainArrived` carries its owner. Also "a empty tank" is now "an empty tank".
|
||||
|
||||
### The Small Yard offered permutations, not trains
|
||||
|
||||
The menu read `re-order consist [1,2,3,0]` — the engine's own array indices offered to a person. The
|
||||
option Jesse wanted was the **first of the five** and he could not tell which one it was. Each option
|
||||
now reads as the train it would build: `re-order to engine · loaded hopper · empty tank · caboose ·
|
||||
loaded boxcar (front to back)`.
|
||||
|
||||
One of the five re-ordered nothing at all and would still have spent a Move — bringing the LAST car
|
||||
to the end is the identity — and a two-car train's "full reversal" duplicated its only real option.
|
||||
Both are filtered by the resulting ORDER rather than by the case that produced them, so a new
|
||||
generator cannot bring either back. No typed-order box: `check` already accepts any permutation, so
|
||||
one is buildable, and Jesse declined it as more to go wrong for a labelling problem.
|
||||
|
||||
### A Small Yard may now put cars ahead of the engine
|
||||
|
||||
Jesse's own open question, discussed and then built. **Two sources disagreed and the design notes
|
||||
won.** The v0.4.5 card text says a train there may "reorder its entire consist **and put the engine
|
||||
at the nose**", which is what `consistSorted` did unconditionally. `implications.md`, drawn from the
|
||||
design source, says a train "may sort itself into any order, **including cars ahead of the engine**"
|
||||
— and records that the Small Yard is the answer the switching puzzle was designed to have.
|
||||
|
||||
`switch.sortConsist` carries an optional `engineAt`, absent meaning the nose, so every save written
|
||||
before this replays to exactly the train it built. **The menu did not multiply.** Offering every car
|
||||
order at every engine position takes a four-car consist from four options to twenty, which is the
|
||||
unreadability that started this; instead the engine is a separate short list offered against the
|
||||
consist as it stands — four re-orders and four engine positions, eight readable options. A player
|
||||
who wants both spends two Moves, the same price the yard charges for any second sort.
|
||||
|
||||
**§8.2 needed no new code, and the reason is worth stating because the obvious guess is wrong.**
|
||||
`badlyMadeUp` is deliberately direction-free: a train with its whole consist ahead of the engine is a
|
||||
PUSHING train and fit to run. What it refuses is a broken-backed train — the engine buried among its
|
||||
own cars — and a caboose anywhere but the end away from the engine. So the hold Jesse asked for was
|
||||
already there, and the button now warns before the Move is spent by asking that same predicate rather
|
||||
than keeping a copy of it.
|
||||
|
||||
That warning immediately earned itself: **every one of the eight options on train 10 is refused by
|
||||
§8.2**, including the arrangement asked for at the table, because that train carries a caboose and
|
||||
each sort moves it off the rear. The menu says so now instead of spending a Move to find out. It is
|
||||
also the correct answer rather than a gap — that train is ALREADY made up, so every sort on offer
|
||||
would break it — and the labels distinguish the two cases outright: `MADE UP, ready to leave` or
|
||||
`HELD at the Office: <why>`.
|
||||
|
||||
**A made-up order is always on the menu for a train that needs one**, which is worth stating because
|
||||
it looks as though it might not be. A yard sort serves two errands — pulling one car out to an end to
|
||||
be spotted, and putting the train back together to leave — and the curated orders are written for the
|
||||
first. They cover the second as a by-product: "bring car k to the tail" is enumerated for EVERY car,
|
||||
so bringing the CABOOSE to the tail is always among them. An explicit "make it up to leave" option
|
||||
was written and then deleted, because it produced exactly that order and was dropped by the dedupe
|
||||
every time.
|
||||
|
||||
### West to east, the way the board draws it
|
||||
|
||||
"Front to back" is not a direction a table can read: which end is the front depends on which way the
|
||||
train is pointed, and the board has drawn the crew strip **west on the left** since v0.8.0 —
|
||||
reversing the consist for an east-facing train so its nose lands at the east end, with the engine as
|
||||
a ◀ or ▶ arrow. The sort labels now read the same way, so the button and the picture describe the
|
||||
same train and "ahead of" and "behind" the engine are read off the strip rather than asserted:
|
||||
|
||||
```
|
||||
re-order — west to east: loaded boxcar · caboose · empty tank · loaded hopper · ENGINE ▶ · HELD at the Office: the caboose must be at the rear of the train
|
||||
put the whole consist ahead of the engine — west to east: ENGINE ▶ · caboose · empty tank · loaded hopper · loaded boxcar
|
||||
```
|
||||
|
||||
`badlyMadeUp`'s "1 car(s) ahead of it" is a real plural now; it reaches a player through the held
|
||||
train's reason as well as through these labels.
|
||||
|
||||
### A role says what it is for
|
||||
|
||||
Tom reached for the Freight Agent to unload passengers, which it has never done — detraining is a
|
||||
**Porter's** action in the Cargo phase. Both halves were working and neither was visible. The
|
||||
Freight Agent, Porter and Laborer groups now carry a sentence saying what the role does, where the
|
||||
role is chosen, rather than leaving a player who picked the wrong one to discover it by finding
|
||||
nothing there.
|
||||
|
||||
## 0.8.0.13 — 2026-09-16
|
||||
|
||||
Nine reports from the Day 1–2 playtest of v0.8.0.12. One was a real bug that cost a car, one was a
|
||||
rule working correctly with nothing on screen to say so, and the rest are things the table could not
|
||||
see.
|
||||
|
||||
### The Division Yard no longer takes a click while your board is behind
|
||||
|
||||
The worst of the batch, because it moved a car. `renderActions` puts the action list away while the
|
||||
queue is catching up — a move offered against a position that has already moved on is a move made
|
||||
blind — but the make-up wiring sat OUTSIDE that guard, so the yard chips stayed lit and clickable.
|
||||
Reported exactly as it happens: a bot was adding the last car, the board lagged, a chip was clicked,
|
||||
a coach left the Division Yard and the train still ended up with three cars. The click had submitted
|
||||
a real intent against a board that was several moves stale.
|
||||
|
||||
Both halves are fixed. The chips are wired only when the queue is idle, and the yard COUNTS are now
|
||||
drawn from the board on screen rather than the live game — they were the one panel still reporting a
|
||||
future the player had not been shown, which is what "the yards may not be in sync with the turns
|
||||
behind" was describing.
|
||||
|
||||
### Every seat has a button, in map order
|
||||
|
||||
The Office Area picker had a button per opponent and none for yourself, so the one player who could
|
||||
not reach their own district was the player waiting on everybody else — watching the board follow
|
||||
whoever was acting, with no way back. Your own seat is now in the row, and `watchedDistrict` returns
|
||||
your board for it rather than falling through to the actor, which is the same bug seen from the other
|
||||
side.
|
||||
|
||||
The buttons are ordered by SEAT — west to east, exactly as the Division map draws it — instead of by
|
||||
player index, which is the order people joined. Seat is not player index and must not be assumed to
|
||||
be: under Employee Rotation the seating moves, and because the row is sorted from the Frame's own
|
||||
`seat` on every render, the buttons rotate with the players rather than having to be told.
|
||||
|
||||
### The Fedora passing is said out loud
|
||||
|
||||
§5 moves the Superintendent at the end of Stages 3, 6, 9 and 12, and the log never mentioned it. The
|
||||
rotation was riding on `actorChanged` — turn bookkeeping, fired every time the cursor moves, which
|
||||
`record()` drops as noise — so the one moment that event carried something a player needed went past
|
||||
in silence, with only "Supervisor Shift" in the history to hint at it.
|
||||
|
||||
It is its own event now (`superintendentChanged`), narrated in the log and announced on screen the
|
||||
way a completed run already is. The phase keeps its name: the Supervisor Shift refreshes every
|
||||
Laborer and Porter EVERY Stage, and the Fedora moves only every third — naming the phase after the
|
||||
rarer event would mislead about the common one.
|
||||
|
||||
### A collision says whose Office it was and who paid for it
|
||||
|
||||
The line named the wreck and the reason and stopped there: the 5-point penalty rides in a separate
|
||||
`revenueChanged`, so a player had to add two log entries together to learn who had just lost five
|
||||
Revenue, and "COLLISION at the Office" never said whose. The faulting seat is in the event — and for
|
||||
anything inside a district that seat IS the district's owner — so the line now reads "COLLISION at
|
||||
Tom's Office … Tom loses 5 Revenue — it happened in their district." A Mainline collision is phrased
|
||||
differently because §10 makes it the Superintendent's, which is a different kind of fault.
|
||||
|
||||
### Passengers: the rule stands, the silence goes
|
||||
|
||||
Reported as a bug and it is not one, which took a replay of the save to establish. The Depot in
|
||||
question had a Restaurant and a Hotel beside it and `cap{out:3}` — capacity was never the problem,
|
||||
and the modifiers grant exactly what they print. What stopped a second passenger was §6.3: stocking
|
||||
takes a LOADED car of the facility's type out of the Division Yard, and there was not a loaded coach
|
||||
in it. Six were sitting in the Classification Yard, which §2.2 returns only when the Division Yard
|
||||
runs bare, and it was holding sixty-odd cars.
|
||||
|
||||
Jesse's ruling is the same one Gitea#2 got: the shortage stays, because running out is part of the
|
||||
game. So the blocked panel now says it — room for N more passengers, no loaded coach in the Division
|
||||
Yard, and how many are waiting in Classification — instead of the action simply being absent from the
|
||||
menu with no reason given.
|
||||
|
||||
### Smaller
|
||||
|
||||
"Working left" is now "working eastward" in the make-up panel and the New Train tip. It was always the
|
||||
same rule — `playerLeftOf` is increasing seat index — but "left" describes a table nobody is looking
|
||||
at, while the map runs west to east, so at a real three-player game the second car went to the player
|
||||
sitting EAST and the text read as wrong. The history panel keeps 90 lines instead of 60, in the same
|
||||
230px box: more to scroll back through, no more screen taken, and the newest line stays where the eye
|
||||
already is.
|
||||
|
||||
### Note for the packaging repo
|
||||
|
||||
`git diff v0.8.0.12..v0.8.0.13 -- src/engine/` is NOT empty this time: `events.ts` declares
|
||||
`superintendentChanged` and `advance.ts` emits it. Both are additive — `check()`, `legal.ts` and
|
||||
every predicate are untouched, and events are derived by replaying a save rather than stored — so no
|
||||
once-legal move became illegal and games in progress resume.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -46,6 +46,15 @@ deliberately no longer names one: it went stale for six releases.
|
||||
from the game. Dwell is assigned **by kind** — a switching move holds the screen, turn bookkeeping
|
||||
costs nothing — and is tunable per viewer without a rebuild. Solitaire runs the same path, which is
|
||||
where its automatic phases finally get a visible beat.
|
||||
|
||||
**The caption and the history panel are not the same list**, since 2026-09-17. Switching is logged
|
||||
in full; a move from the middle of a turn writes its line as tone `trace`, so the step still
|
||||
carries it — the board captions the move and earns its dwell, and `dwellForStep` pays nothing for a
|
||||
step that said nothing — while the history panel filters the tone out. What the panel draws is the
|
||||
line saying somebody switched, the FIRST move, work at an **industry**, the Small Yard sort, and a
|
||||
closing summary. The last move rides in that summary rather than being kept in place: nothing knows
|
||||
a move was the last until the turn is over, by which time the line has been written and streamed to
|
||||
every client, so it cannot be revised.
|
||||
**Not yet checked in a browser:** the mechanism is proven server-side against a live SSE stream and
|
||||
the page is proven not to throw, but nobody has watched a bot switch on screen.
|
||||
- **Not built** — the opponent-directed cards (the Action and Space-use categories, held out of every
|
||||
|
||||
@@ -79,7 +79,7 @@ Not items. Things that are true of every change, and that have gone wrong when s
|
||||
3. **Multiplayer, sessions and operations** — #8 #7 #76 #77 #79
|
||||
4. **The screen** — #44 #81 #33 #36
|
||||
5. **Replays and saved games** — #14 #47 #48 #49 #50 #51 #52
|
||||
6. **Rules** — #12 #80 #82 #83 #85
|
||||
6. **Rules** — #12 #80 #82 #83 #85 #108
|
||||
7. **Play balance** — #61 #62 #63 #64 #67 #68 #69 #70 #71 #72 #73 #66 #65 #74
|
||||
8. **The bot** — #104 #105 #106 #41 #57 #59 #54 #58 #55 #56 #60
|
||||
9. **Code health and housekeeping** — #46 #45 #84 #87
|
||||
@@ -407,6 +407,42 @@ need RAR or Jesse rather than code.**
|
||||
- [ ] **#85** — The 0.4.9 playtest line is behind on a rules ruling, and that was checked rather than
|
||||
assumed. See **Reference · #85**.
|
||||
|
||||
- [x] **#107** — **May a Small Yard put cars on the NOSE of the engine? YES** — raised by Jesse
|
||||
2026-09-17, discussed the same day and built. Two sources disagreed: the v0.4.5 card text says
|
||||
a train there reorders "and puts the engine at the nose", `implications.md` says "any order,
|
||||
INCLUDING cars ahead of the engine". The design notes won.
|
||||
|
||||
`switch.sortConsist` gained an optional `engineAt` (absent = the nose, so older saves replay
|
||||
unchanged). The menu did NOT multiply: the engine is a separate short list offered against the
|
||||
consist as it stands, so a four-car train has eight options rather than twenty, and a player
|
||||
wanting both a re-order and an engine move spends two Moves. §8.2 needed no new code —
|
||||
`badlyMadeUp` already holds a broken-backed train, and is deliberately direction-free, so a
|
||||
PUSHING train (whole consist ahead of the engine) is fit to run. The button warns first, by
|
||||
asking that predicate rather than copying it.
|
||||
|
||||
Labels read WEST TO EAST with the engine drawn as the board's own ◀ / ▶ arrow, because "front
|
||||
to back" depends on which way the train points and the board has reversed east-facing consists
|
||||
since v0.8.0. Each says `MADE UP, ready to leave` or `HELD at the Office: <why>`.
|
||||
|
||||
- [ ] **#108** — **The coach ratchet: every coach ends up in the Classification Yard and never comes
|
||||
back.** RULED 2026-09-17 — *the rule stands, the game says so loudly* — and recorded here
|
||||
because the ruling was made on one game's evidence and the balance question behind it is open.
|
||||
|
||||
§9.2 boarding discards the emptied coach into **Classification**; detraining draws a fresh
|
||||
empty **out of the Division Yard**; §2.2 returns Classification only when the Division Yard runs
|
||||
bare. Coaches therefore move one way only. **Measured over `whistle-6945` (3 Days, 539
|
||||
intents):** 16 coaches in the Division Yard at setup, **0 from Day 2 Stage 8 to the end**, 15
|
||||
in Classification — while the Division Yard held steady at 46-47 freight cars, so the refill
|
||||
could not fire. From that point no passenger can board or detrain anywhere on the board, and
|
||||
four of the twelve timetabled trains (1/2 Crack Limited, 5/6 Sparrow) carry nothing but
|
||||
coaches.
|
||||
|
||||
The two changes that would break the ratchet were put up and declined for now: sending the
|
||||
emptied coach back to the **Division** Yard instead of Classification (a one-line change to the
|
||||
boarding reducer), or amending §2.2 to refill when the Division Yard holds no car of a NEEDED
|
||||
type rather than only when bare. **Revisit with a second game's data** — one game cannot tell a
|
||||
rule from a seed.
|
||||
|
||||
---
|
||||
|
||||
## Play balance
|
||||
|
||||
@@ -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
|
||||
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
|
||||
[`overview.md`](overview.md) keeps that boundary sharp.
|
||||
|
||||
|
||||
@@ -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 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.
|
||||
- **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.
|
||||
- **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. · 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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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. · 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.
|
||||
|
||||
---
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "station-master",
|
||||
"version": "0.8.0.10",
|
||||
"version": "0.8.0.14",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Station Master — a railroad operations game",
|
||||
|
||||
+10
-4
@@ -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
|
||||
* 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
|
||||
* environment, and stays honest: two builds of the same commit ARE two deploys, and a cache key
|
||||
* that says so costs one refetch, while one that lies costs a release nobody receives.
|
||||
* A marker plus the build's own timestamp is always distinct, needs nothing from the environment,
|
||||
* and stays honest: two builds of the same commit ARE two deploys, and a cache key that says so
|
||||
* 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 {
|
||||
const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root })
|
||||
.toString()
|
||||
|
||||
+51
-3
@@ -31,12 +31,13 @@ import {
|
||||
houseRules,
|
||||
officeProfile,
|
||||
mainlineProfile,
|
||||
consistSize,
|
||||
} from './content.ts';
|
||||
import type { Direction, MainlineEntry, MainlineKind } from './content.ts';
|
||||
import type { CarType, Direction, MainlineEntry, MainlineKind } from './content.ts';
|
||||
import type { GameEvent } from './events.ts';
|
||||
// `trainNeedingCars` lives in apply.ts beside `check`'s copy of the same question, so the phase and
|
||||
// the legality test cannot disagree about which train is being assembled.
|
||||
import { areaAtSeat, areaOf, occupancyFor, trainNeedingCars } from './apply.ts';
|
||||
import { acceptsCar, areaAtSeat, areaOf, isBeingMadeUp, occupancyFor, trainNeedingCars } from './apply.ts';
|
||||
import { legalActions } from './legal.ts';
|
||||
import type { CrewTray, DivisionNode, GameState, GridCoord, Outcome, PlayerIndex, RollingStock, SeatIndex, TrayId } from './state.ts';
|
||||
import { cloneTally, coordKey, freshTurns, isExtendable, playerAtSeat, playerLeftOf, pooled, railFacingOf, subdivisions, totalRevenue, turnOf } from './state.ts';
|
||||
@@ -393,6 +394,45 @@ function newTrainPhase(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
return { events, needsInput: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* SAY SO WHEN A TRAIN GOT NOTHING, before the round is over and the train runs (playtest,
|
||||
* 2026-09-16: "train 5, the sparrow, has no coaches, which seems strange").
|
||||
*
|
||||
* `trainNeedingCars` returns null both when every consist is full and when the Division Yard holds
|
||||
* nothing a short train will take — the same answer for "done" and for "cannot be done" — so the
|
||||
* phase moved on in silence and the only trace was a MADE UP line promising "now taking cars". The
|
||||
* Sparrow calls for three coaches and left empty twice in one game.
|
||||
*
|
||||
* REPORTED HERE RATHER THAN AT THE MADE-UP MOMENT, because a train made up early in the round can
|
||||
* still be filled by a later placement; only once the round has nothing left to offer is the
|
||||
* shortfall a fact. This is reached exactly once per Stage — the next line enters the Mainline
|
||||
* Phase — so the report cannot repeat.
|
||||
*/
|
||||
for (const tray of s.trays.values()) {
|
||||
if (!isBeingMadeUp(tray) || tray.trainNumber === null) continue;
|
||||
const profile = trainProfile(tray.trainNumber, tray.trainIsExtra);
|
||||
if (!profile) continue;
|
||||
const category = (t: CarType): 'freight' | 'coach' | 'caboose' =>
|
||||
t === 'coach' ? 'coach' : t === 'caboose' ? 'caboose' : 'freight';
|
||||
// What the card still wants: asked of `acceptsCar` per category, so a full category and a
|
||||
// category barred by the card's own rules answer the same way here as they do to a player.
|
||||
const missing = (['freight', 'coach', 'caboose'] as const).filter((cat) => {
|
||||
const sample: CarType = cat === 'coach' ? 'coach' : cat === 'caboose' ? 'caboose' : 'boxcar';
|
||||
return acceptsCar(tray, sample);
|
||||
});
|
||||
if (missing.length === 0) continue;
|
||||
events.push({
|
||||
type: 'makeUpShort',
|
||||
trainNumber: tray.trainNumber,
|
||||
isExtra: tray.trainIsExtra,
|
||||
placed: tray.consist.length,
|
||||
wanted: consistSize(profile.consist),
|
||||
missing: [...missing],
|
||||
waiting: s.yards.classificationYard.filter((c) => missing.includes(category(c.type))).length,
|
||||
divisionYardHolds: s.yards.divisionYard.length,
|
||||
});
|
||||
}
|
||||
|
||||
return { events: [...events, ...enterPhase(s, 'mainline')], needsInput: false };
|
||||
}
|
||||
|
||||
@@ -544,7 +584,7 @@ export function badlyMadeUp(tray: CrewTray): string | null {
|
||||
const pulling = tray.engineAt === 0;
|
||||
const pushing = tray.engineAt === n;
|
||||
if (!pulling && !pushing) {
|
||||
return `not made up — the engine is buried in the train, ${tray.engineAt} car(s) ahead of it`;
|
||||
return `not made up — the engine is buried in the train, ${tray.engineAt} car${tray.engineAt === 1 ? '' : 's'} ahead of it`;
|
||||
}
|
||||
const caboose = tray.consist.findIndex((c) => c.type === 'caboose');
|
||||
if (caboose === -1) return null;
|
||||
@@ -1484,6 +1524,7 @@ function arriveAtOffice(
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
consist: tray.consist.map((c) => ({ ...c })),
|
||||
office: officeProfile(area.tier).name,
|
||||
owner: playerAtSeat(s, seat),
|
||||
expedited: isExpedited(tray),
|
||||
});
|
||||
|
||||
@@ -1655,6 +1696,13 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
if (s.clock.stage % STAGES_PER_SHIFT === 0) {
|
||||
s.clock.superintendent = playerLeftOf(s, s.clock.superintendent);
|
||||
events.push({ type: 'actorChanged', player: s.clock.superintendent });
|
||||
/**
|
||||
* SAID OUT LOUD, as well as recorded. `actorChanged` is turn bookkeeping and the log discards it,
|
||||
* so this — the one time in three Stages that it means the Fedora moved — had no line anywhere
|
||||
* (playtest, 2026-09-16). Emitted alongside rather than instead: `actorChanged` still carries the
|
||||
* cursor, and anything reading it keeps working.
|
||||
*/
|
||||
events.push({ type: 'superintendentChanged', player: s.clock.superintendent, stage: s.clock.stage });
|
||||
}
|
||||
|
||||
// §9.1 — Laborers and Porters reset at the start of each Stage, not each Phase.
|
||||
|
||||
+95
-16
@@ -974,6 +974,16 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
const seen = new Set(i.order);
|
||||
if (seen.size !== i.order.length) return 'CONSIST_ORDER';
|
||||
if (i.order.some((n) => n < 0 || n >= tray.consist.length)) return 'CONSIST_ORDER';
|
||||
/**
|
||||
* The engine may finish anywhere in the train, including with cars ahead of it (Jesse,
|
||||
* 2026-09-17). `engineAt` indexes the SORTED consist, so `consist.length` is legal and means
|
||||
* the engine on the tail with everything ahead of it — the shoving case a Small Yard exists to
|
||||
* set up. Refused outside that range rather than clamped: a clamp would silently build a
|
||||
* different train from the one the player asked for.
|
||||
*/
|
||||
if (i.engineAt !== undefined && (i.engineAt < 0 || i.engineAt > tray.consist.length)) {
|
||||
return 'CONSIST_ORDER';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1426,17 +1436,30 @@ function checkPlay(
|
||||
if (!placement) return 'NO_PLACEMENT';
|
||||
if (area.grid.has(coordKey(placement))) return 'NOT_CONNECTED';
|
||||
/**
|
||||
* NOT ON THE RUNNING TRACK ROW — AND NOT BOUNDED BY THE LIMITS EITHER. Jesse's call, both
|
||||
* halves.
|
||||
* NEITHER ON THE RUNNING TRACK ROW NOR OUTSIDE THE LIMITS — Jesse's call, both halves, the
|
||||
* second REVERSED on 2026-09-17 after a Day 3 playtest.
|
||||
*
|
||||
* A Modifier is not track (§9), so unlike a siding it may hang outside the Limits: a Facility
|
||||
* standing at the limit has three of its nine spots out there, and refusing them would make
|
||||
* the card unplayable exactly where the district ends. What it may NOT do is stand in the row
|
||||
* the Running Track grows along. Inside the Limits that row is always full, so this bites only
|
||||
* beyond the sign — which is the ground the main extends onto, and a Modifier parked there
|
||||
* would block your own sign from moving outward (§2.1) with nothing on screen to warn you.
|
||||
* It used to read the other way: a Modifier is not track (§9), so unlike a siding it could
|
||||
* hang outside the Limits, because a Facility standing at the limit has three of its nine
|
||||
* spots out there and refusing them would make the card unplayable exactly where the district
|
||||
* ends. What that argument missed is what the board then shows — Transmission Lines at (-2,4)
|
||||
* with the sign at column 3 — which reads as building outside your own territory, and §8.1 and
|
||||
* §10 both reason about what lies inside a player's Limits.
|
||||
*
|
||||
* THE UNPLAYABLE CASE WAS CHECKED ON THE REPORTED MOVE, not assumed away: the Power Plant was
|
||||
* at (-1,3) against a sign at column 3, and (-2,2) and (-2,3) were free, legal and inside. Six
|
||||
* of the nine spots survive a Facility at the limit, and the sign moves outward as the Running
|
||||
* Track grows (§2.1, Gap 4a), so the ground arrives with the district.
|
||||
*
|
||||
* The Running Track row stays barred for its own reason: inside the Limits that row is always
|
||||
* full, so it bit only beyond the sign, where a Modifier would block the sign from moving
|
||||
* outward with nothing on screen to warn you. That ground is now out of bounds anyway, which
|
||||
* makes this the narrower rule rather than a redundant one — the row is barred INSIDE the
|
||||
* Limits too, where a square can fall vacant if the main is rebuilt around it.
|
||||
*/
|
||||
if (placement.row === area.runningRow) return 'ON_RUNNING_TRACK';
|
||||
// §2.1 — a district's cards belong inside its own sign, Modifiers included since 2026-09-17.
|
||||
if (!withinLimits(area, placement)) return 'OUTSIDE_LIMITS';
|
||||
// One of a kind per Office Area, as with industries (Q4) — no district gets two Ice Houses.
|
||||
if (hasModifierInArea(area, card.kind.modifier)) return 'FACILITY_LOCKED';
|
||||
// §9 — a Modifier is not track. It must sit adjacent to a Facility THAT CAN HOST IT (one of
|
||||
@@ -1680,6 +1703,9 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
from,
|
||||
to: i.to,
|
||||
movesRemaining: turnOf(s, player).movesRemaining - 1,
|
||||
// "3 of 6" was written with the 6 hardcoded in the narrator, which is wrong on a night
|
||||
// Stage under Reduced Visibility, where a turn gets five. The turn knows; the event carries.
|
||||
movesAllowed: turnOf(s, player).movesAllowed,
|
||||
/**
|
||||
* A TRAIN THAT BACKS UP HAS NOT TURNED AROUND — AND A CURVE IS NOT A STRAIGHT.
|
||||
*
|
||||
@@ -1775,11 +1801,29 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
at: here,
|
||||
before: tray.consist.map((c) => ({ ...c })),
|
||||
after: i.order.map((n) => ({ ...tray.consist[n]! })),
|
||||
// Absent means the nose, which is what every sort did before 2026-09-17 — so an older save
|
||||
// replays to exactly the train it built.
|
||||
engineAt: i.engineAt ?? 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
case 'switch.end':
|
||||
/**
|
||||
* The closing summary comes BEFORE `phaseEnded`, so the history reads as the turn ending rather
|
||||
* than as a postscript to it. Split out of the shared case below for that one line.
|
||||
*/
|
||||
case 'switch.end': {
|
||||
const turn = turnOf(s, player);
|
||||
const ended: GameEvent = {
|
||||
type: 'switchingEnded',
|
||||
player,
|
||||
movesUsed: turn.movesAllowed - turn.movesRemaining,
|
||||
movesAllowed: turn.movesAllowed,
|
||||
...(turn.lastMove ? { lastMove: turn.lastMove } : {}),
|
||||
};
|
||||
return [ended, { type: 'phaseEnded', player, phase: 'localOps' }];
|
||||
}
|
||||
|
||||
case 'draw.end':
|
||||
case 'freightAgent.end':
|
||||
return [{ type: 'phaseEnded', player, phase: 'localOps' }];
|
||||
@@ -1956,18 +2000,41 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
];
|
||||
}
|
||||
|
||||
case 'newTrain.placeCar':
|
||||
/**
|
||||
* THE TRAIN'S NUMBER RIDES ALONG (playtest, 2026-09-16: "I did not see anything in the history
|
||||
* about making up train 10 and how each person added each car to it").
|
||||
*
|
||||
* It was all there — a MADE UP line and one line per car — but every one of those lines read
|
||||
* "the train being made up", so a player scanning the history for train 10 found nothing under
|
||||
* that name. The tray id is no use to a reader and the narrator has no state to look it up in,
|
||||
* so the number travels with the event, exactly as `owner` does on `trainArrived`.
|
||||
*/
|
||||
case 'newTrain.placeCar': {
|
||||
const placeTray = s.trays.get(i.trayId);
|
||||
return [
|
||||
{
|
||||
type: 'carPlacedOnTrain',
|
||||
player,
|
||||
trayId: i.trayId,
|
||||
stock: { type: i.carType, loaded: i.loaded },
|
||||
trainNumber: placeTray?.trainNumber ?? null,
|
||||
isExtra: placeTray?.trainIsExtra ?? false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
case 'newTrain.passCar':
|
||||
return [{ type: 'carPassed', player, trayId: i.trayId }];
|
||||
case 'newTrain.passCar': {
|
||||
const passTray = s.trays.get(i.trayId);
|
||||
return [
|
||||
{
|
||||
type: 'carPassed',
|
||||
player,
|
||||
trayId: i.trayId,
|
||||
trainNumber: passTray?.trainNumber ?? null,
|
||||
isExtra: passTray?.trainIsExtra ?? false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
case 'newTrain.secondSection':
|
||||
return [{ type: 'secondSectionOrdered', player, trainNumber: i.trainNumber }];
|
||||
@@ -2141,7 +2208,10 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
}
|
||||
// Only the player sitting in this district can be switching this tray, so the Moves come off
|
||||
// their turn. The event carries no player of its own.
|
||||
turnOf(s, playerAtSeat(s, seat)).movesRemaining = e.movesRemaining;
|
||||
const mover = turnOf(s, playerAtSeat(s, seat));
|
||||
mover.movesRemaining = e.movesRemaining;
|
||||
// Where the crew was left, for the line that closes the turn — see `switchingEnded`.
|
||||
mover.lastMove = { trayId: e.trayId, to: e.to };
|
||||
|
||||
const area = areaAtSeat(s, seat);
|
||||
|
||||
@@ -2219,9 +2289,18 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
case 'consistSorted': {
|
||||
const tray = s.trays.get(e.trayId)!;
|
||||
tray.consist = e.after.map((c) => ({ ...c }));
|
||||
// A Small Yard re-makes the train, and putting the engine back on the nose is the whole reason
|
||||
// to use one: §8.2 will not let a train leave the Office with cars in front of its engine.
|
||||
tray.engineAt = 0;
|
||||
/**
|
||||
* WHERE THE SORT PUT THE ENGINE — 0 on every sort before 2026-09-17, and on most of them
|
||||
* since, because putting the engine back on the nose is what a Small Yard is usually for:
|
||||
* §8.2 will not let a train leave the Office with cars in front of its engine.
|
||||
*
|
||||
* It is no longer forced. The design source (`implications.md`) has always said a train here
|
||||
* "may sort itself into any order, INCLUDING cars ahead of the engine", against a v0.4.5 card
|
||||
* text that says the engine ends at the nose; Jesse settled it for the source. A numbered
|
||||
* train left nose-loaded is held at the Office by §8.2 until it sorts again — see
|
||||
* `departureRefusal`.
|
||||
*/
|
||||
tray.engineAt = e.engineAt;
|
||||
// "Spends one move in the yard" — the sort costs a Move.
|
||||
const sorter = turnOf(s, playerAtSeat(s, trayySeat(tray)));
|
||||
sorter.movesRemaining = Math.max(0, sorter.movesRemaining - 1);
|
||||
|
||||
+11
-1
@@ -772,7 +772,17 @@ export function mainlineDescription(kind: MainlineKind, gradeUp: Direction = 'ea
|
||||
`${stages(run({}))}.`,
|
||||
);
|
||||
} 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') {
|
||||
|
||||
+80
-5
@@ -28,6 +28,15 @@ export type GameEvent =
|
||||
| { type: 'stageBegan'; day: number; stage: number }
|
||||
/** Employee Rotation (Appendix B) — every player has moved one chair left for the new Day. */
|
||||
| { type: 'seatsRotated'; day: number; seating: PlayerIndex[] }
|
||||
/**
|
||||
* §5 — the Fedora passed, at the end of Stage 3, 6, 9 or 12.
|
||||
*
|
||||
* ITS OWN EVENT RATHER THAN THE `actorChanged` THIS USED TO RIDE ON. That one is turn bookkeeping,
|
||||
* fired every time the cursor moves, and `record()` drops it on the floor as noise — so the one
|
||||
* moment it carried that a player actually needed to see went past in silence. Reported from the
|
||||
* table (2026-09-16): the Supervisor Shift appears in the history and the handover never does.
|
||||
*/
|
||||
| { type: 'superintendentChanged'; player: PlayerIndex; stage: number }
|
||||
| { type: 'phaseBegan'; phase: string }
|
||||
| { type: 'actorChanged'; player: PlayerIndex | null }
|
||||
// -- local operations
|
||||
@@ -37,7 +46,7 @@ export type GameEvent =
|
||||
* more than one legal route to `to`, so the history can say which one ran rather than leaving a
|
||||
* choice the player made invisible in their own log.
|
||||
*/
|
||||
| { type: 'trayMoved'; player: PlayerIndex; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number; facing?: 'n' | 's' | 'e' | 'w'; via?: GridCoord }
|
||||
| { type: 'trayMoved'; player: PlayerIndex; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number; movesAllowed: number; facing?: 'n' | 's' | 'e' | 'w'; via?: GridCoord }
|
||||
| {
|
||||
type: 'carsCoupled';
|
||||
player: PlayerIndex;
|
||||
@@ -73,7 +82,39 @@ export type GameEvent =
|
||||
recoupled?: { at: GridCoord; stock: RollingStock[] };
|
||||
}
|
||||
| { type: 'carsDropped'; player: PlayerIndex; trayId: TrayId; at: GridCoord; stock: RollingStock[]; fromNose?: boolean }
|
||||
| { type: 'consistSorted'; player: PlayerIndex; trayId: TrayId; at: GridCoord; before: RollingStock[]; after: RollingStock[] }
|
||||
| {
|
||||
type: 'consistSorted';
|
||||
player: PlayerIndex;
|
||||
trayId: TrayId;
|
||||
at: GridCoord;
|
||||
before: RollingStock[];
|
||||
after: RollingStock[];
|
||||
/**
|
||||
* Where the engine ends up in `after`, counted as an index into it — 0 is the nose.
|
||||
*
|
||||
* The Small Yard used to put the engine back on the front unconditionally, which is what the
|
||||
* v0.4.5 card text says ("reorder its entire consist and put the engine at the nose").
|
||||
* `implications.md` records the design source saying the opposite — "may sort itself into any
|
||||
* order, INCLUDING cars ahead of the engine" — and Jesse settled it that way on 2026-09-17.
|
||||
*/
|
||||
engineAt: number;
|
||||
}
|
||||
/**
|
||||
* A player finished their switching turn: what it cost, and where the crew was left.
|
||||
*
|
||||
* The history panel keeps a switching turn's FIRST move and drops the ones in the middle, so the
|
||||
* closing line is where "and it ended up here" has to come from. It cannot be recovered by
|
||||
* revealing the last `trayMoved` after the fact: the log streams to clients as it is written
|
||||
* (`server/session.ts` § linesSince), and nobody knows a move was the last one until the turn is
|
||||
* already over and that line has been sent.
|
||||
*/
|
||||
| {
|
||||
type: 'switchingEnded';
|
||||
player: PlayerIndex;
|
||||
movesUsed: number;
|
||||
movesAllowed: number;
|
||||
lastMove?: { trayId: TrayId; to: GridCoord };
|
||||
}
|
||||
| { type: 'cardDrawn'; player: PlayerIndex; source: 'homeOffice' | 'department'; slot?: number; cardId: CardId }
|
||||
/**
|
||||
* §6.2 — the Home Office deck ran out, so the Salvage Yard and all three Department decks were
|
||||
@@ -159,7 +200,13 @@ export type GameEvent =
|
||||
* 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.
|
||||
*/
|
||||
| { 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 }
|
||||
/**
|
||||
* The train ran the length of the Division and left it. `side` is the Division Point it left by,
|
||||
@@ -180,8 +227,36 @@ export type GameEvent =
|
||||
at: ExtraStart;
|
||||
direction: Direction;
|
||||
}
|
||||
| { type: 'carPlacedOnTrain'; player: PlayerIndex; trayId: TrayId; stock: RollingStock }
|
||||
| { type: 'carPassed'; player: PlayerIndex; trayId: TrayId }
|
||||
| { type: 'carPlacedOnTrain'; player: PlayerIndex; trayId: TrayId; stock: RollingStock; trainNumber: number | null; isExtra: boolean }
|
||||
| { type: 'carPassed'; player: PlayerIndex; trayId: TrayId; trainNumber: number | null; isExtra: boolean }
|
||||
/**
|
||||
* A train was made up and the round could give it NOTHING THE CARD CALLS FOR — the Division Yard
|
||||
* holds no car of a category it still wants (§7, §8.2 "may depart with fewer").
|
||||
*
|
||||
* ITS OWN EVENT BECAUSE THE SILENCE WAS THE BUG (playtest, 2026-09-16: "train 5, the sparrow, has
|
||||
* no coaches, which seems strange"). `trainNeedingCars` returns null in exactly this case, so the
|
||||
* phase never stops, nobody is asked for a car, and the only trace was a MADE UP line promising
|
||||
* "now taking cars" with nothing after it. The train then ran the whole Division empty.
|
||||
*
|
||||
* CARRIES WHY, not just that. The shortage is a standing condition rather than a moment — §2.2
|
||||
* returns the Classification Yard only when the Division Yard runs bare — so the counts that
|
||||
* explain it have to travel with the event: what is still wanted, how many such cars are waiting
|
||||
* in Classification, and how far the Division Yard is from empty.
|
||||
*/
|
||||
| {
|
||||
type: 'makeUpShort';
|
||||
trainNumber: number;
|
||||
isExtra: boolean;
|
||||
/** How many cars it got, out of what the card calls for. */
|
||||
placed: number;
|
||||
wanted: number;
|
||||
/** The categories the card still wants and the Division Yard cannot supply. */
|
||||
missing: ('freight' | 'coach' | 'caboose')[];
|
||||
/** Cars of those categories sitting in the Classification Yard. */
|
||||
waiting: number;
|
||||
/** §2.2 — Classification comes back only when this reaches zero. */
|
||||
divisionYardHolds: number;
|
||||
}
|
||||
| { type: 'dispatchBonusUsed'; key: string; bonus: number; trainNumber: number; againstTrain: number }
|
||||
| { type: 'clearanceRequested'; trainId: TrayId; occupiedBy: TrayId }
|
||||
| { type: 'clearanceGiven'; trainId: TrayId; allow: boolean }
|
||||
|
||||
+13
-1
@@ -47,7 +47,19 @@ export type Intent =
|
||||
* order, including cars in front of the engine". This is the designed answer to §A.3's
|
||||
* come-off-in-seated-order constraint, which is what makes facing-point work possible.
|
||||
*/
|
||||
| { type: 'switch.sortConsist'; trayId: TrayId; order: number[] }
|
||||
/**
|
||||
* §Enhancements, Small Yard — one Move to re-make a train standing on the yard.
|
||||
*
|
||||
* `order` is a permutation of the current consist, nose first. `engineAt` is where the LOCOMOTIVE
|
||||
* ends up in it: 0 puts it back on the front, which is what the v0.4.5 card text describes and
|
||||
* what this action did unconditionally until 2026-09-17. `implications.md` records the design
|
||||
* source saying a train here "may sort itself into any order, including cars ahead of the engine",
|
||||
* and Jesse ruled that way — so it is a number now, and a train left nose-loaded is one §8.2 will
|
||||
* not let out of the Office until it is sorted again.
|
||||
*
|
||||
* Optional, defaulting to 0, so every save written before this replays exactly as it did.
|
||||
*/
|
||||
| { type: 'switch.sortConsist'; trayId: TrayId; order: number[]; engineAt?: number }
|
||||
| { type: 'switch.end' }
|
||||
// -- draw (§6.2)
|
||||
| { type: 'draw.fromHomeOffice' }
|
||||
|
||||
+68
-6
@@ -91,17 +91,79 @@ function switchCandidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||
// that shoved a cut, and therefore the only way an engine buried mid-train reaches an end.
|
||||
out.push({ type: 'switch.dropCars', trayId, count: n, fromNose: true });
|
||||
}
|
||||
// Small Yard: enumerating every permutation would explode, so offer the useful ones —
|
||||
// bringing each car to the droppable end, plus a full reversal. `check` validates any order,
|
||||
// so a UI may submit an arbitrary permutation.
|
||||
/**
|
||||
* Small Yard: enumerating every permutation would explode, so offer the useful ones — bringing
|
||||
* each car to the droppable end, plus a full reversal. `check` validates any order, so a UI may
|
||||
* submit an arbitrary permutation.
|
||||
*
|
||||
* NOTHING THAT RE-ORDERS NOTHING. Bringing the LAST car to the end is the identity, and a
|
||||
* two-car train's reversal repeats its only real option — so the menu carried a move that spent
|
||||
* one of six Moves to leave the train exactly as it was, beside a duplicate of the move next to
|
||||
* it. Both were invisible while the labels were index lists (playtest, 2026-09-17); both are
|
||||
* plainly wrong once the label reads as a train. Filtered by the ORDER rather than by the case
|
||||
* that produced it, so a new generator cannot reintroduce either.
|
||||
*/
|
||||
const n = tray.consist.length;
|
||||
const identity = [...Array(n).keys()];
|
||||
if (n > 1) {
|
||||
const orders: number[][] = [];
|
||||
for (let k = 0; k < n; k++) {
|
||||
const order = [...Array(n).keys()].filter((x) => x !== k);
|
||||
const order = identity.filter((x) => x !== k);
|
||||
order.push(k);
|
||||
out.push({ type: 'switch.sortConsist', trayId, order });
|
||||
orders.push(order);
|
||||
}
|
||||
orders.push([...identity].reverse());
|
||||
// Nothing that re-orders nothing: the current train is the one thing on offer that costs a
|
||||
// Move and changes the board not at all. Keyed by (order, engine position) together, since
|
||||
// since 2026-09-17 the same car order at a different engine position is a different train.
|
||||
const seen = new Set<string>([`${identity.join(',')}|${tray.engineAt}`]);
|
||||
const offer = (order: number[], engineAt: number): void => {
|
||||
const key = `${order.join(',')}|${engineAt}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
out.push({ type: 'switch.sortConsist', trayId, order, engineAt });
|
||||
};
|
||||
// The car orders, each leaving the engine on the nose — the Small Yard's ordinary use.
|
||||
for (const order of orders) offer(order, 0);
|
||||
/**
|
||||
* A MADE-UP ORDER IS ALWAYS AMONG THESE, which is worth saying because it looks as though it
|
||||
* might not be (Jesse, 2026-09-17: "trains with a caboose have to offer the caboose at the
|
||||
* back").
|
||||
*
|
||||
* A yard sort serves two errands — pulling one car out to an end so it can be spotted, and
|
||||
* putting the train back together to leave — and the orders above are written for the first.
|
||||
* They cover the second as a by-product: "bring car k to the tail" is enumerated for EVERY car,
|
||||
* so bringing the CABOOSE to the tail is always one of them, and with the engine on the nose
|
||||
* that is a train §8.2 will let out of the Office.
|
||||
*
|
||||
* An explicit "make it up to leave" option was written here and deleted: it produced exactly
|
||||
* the k-is-the-caboose order and was dropped by the dedupe every time. The one case where no
|
||||
* made-up order appears is a train that is ALREADY made up, where such an option would be the
|
||||
* identity — and the labels say which is which, so a player can see that every offer would
|
||||
* break a train that is currently fit to run.
|
||||
*/
|
||||
}
|
||||
/**
|
||||
* WHERE THE ENGINE GOES, as its own short list rather than multiplied through the one above
|
||||
* (Jesse's call, 2026-09-17: "a separate engine control").
|
||||
*
|
||||
* Offering every car order at every engine position is the honest enumeration and it is
|
||||
* unreadable: a four-car consist would go from four options to twenty, which is the labelling
|
||||
* problem that prompted all of this. So the engine positions are offered against the consist AS
|
||||
* IT STANDS — pick an order, or pick where the engine sits, each one Move. A player who wants
|
||||
* both spends two, which is the same price the yard charges for any second sort.
|
||||
*
|
||||
* OFFERED FOR A ONE-CAR TRAIN TOO, unlike the car orders: a single car ahead of the engine or
|
||||
* behind it is exactly the difference between shoving it into a facing industry and pulling it.
|
||||
*/
|
||||
if (n >= 1) {
|
||||
const seenEngine = new Set<string>([`${identity.join(',')}|${tray.engineAt}`]);
|
||||
for (let k = 0; k <= n; k++) {
|
||||
const key = `${identity.join(',')}|${k}`;
|
||||
if (seenEngine.has(key)) continue;
|
||||
seenEngine.add(key);
|
||||
out.push({ type: 'switch.sortConsist', trayId, order: identity, engineAt: k });
|
||||
}
|
||||
out.push({ type: 'switch.sortConsist', trayId, order: [...Array(n).keys()].reverse() });
|
||||
}
|
||||
}
|
||||
// Flying Switch — roll a cut into an ADJACENT industry without the engine entering it.
|
||||
|
||||
@@ -875,6 +875,25 @@ export type FinalReport = {
|
||||
export type TurnState = {
|
||||
option: 'switch' | 'draw' | 'freightAgent' | null;
|
||||
movesRemaining: number;
|
||||
/**
|
||||
* What `movesRemaining` started at this Stage — six, or five under Reduced Visibility at night.
|
||||
*
|
||||
* CARRIED RATHER THAN ASSUMED. Every reader of `movesRemaining` that wanted to say "3 of 6" had
|
||||
* hardcoded the 6, which is simply wrong on a night Stage, and the only other way to recover it is
|
||||
* to re-derive `movesForStage` outside the phase driver that owns it. It also makes "is this the
|
||||
* FIRST move of the turn?" a comparison rather than a guess, which is what the history panel needs
|
||||
* to keep the opening move of a switching turn and drop the ones in the middle.
|
||||
*/
|
||||
movesAllowed: number;
|
||||
/**
|
||||
* The last square this player's crew moved to this Stage, and which crew it was.
|
||||
*
|
||||
* Switching ends with a summary line, and "where did the train end up" is the half of it a player
|
||||
* actually wants. It cannot be recovered from the log: the line naming the last move is written
|
||||
* before anyone knows it was the last, and the log streams to clients as it is written, so a line
|
||||
* already sent cannot be revised afterwards.
|
||||
*/
|
||||
lastMove?: { trayId: TrayId; to: GridCoord };
|
||||
drawnThisTurn: boolean;
|
||||
freightAgentUsed: boolean;
|
||||
/**
|
||||
@@ -1057,6 +1076,7 @@ export function freshTurn(moves: number): TurnState {
|
||||
return {
|
||||
option: null,
|
||||
movesRemaining: moves,
|
||||
movesAllowed: moves,
|
||||
drawnThisTurn: false,
|
||||
freightAgentUsed: false,
|
||||
freightWorked: {},
|
||||
|
||||
+13
-4
@@ -684,10 +684,19 @@ export function carriesThroughTrack(card: TrackCard): boolean {
|
||||
* buildable column and break §11.3's promise that both Secondary rows, and the nine-spot Modifier
|
||||
* neighbourhood, are usable from the first Stage.
|
||||
*
|
||||
* MODIFIERS ARE NOT SUBJECT TO THIS, and are not track: §9 places one on any of the nine spots
|
||||
* around a Facility, and a Facility standing at the limit has three of its nine outside them.
|
||||
* Jesse's call. `check` bars them from the Running Track ROW instead, which is the ground the main
|
||||
* grows onto.
|
||||
* MODIFIERS ARE SUBJECT TO THIS TOO, since 2026-09-17 — REVERSING an earlier call of Jesse's that
|
||||
* exempted them. The exemption reasoned that §9 places a Modifier on any of the nine spots around a
|
||||
* Facility, so a Facility standing at the limit has three of its nine outside them and bounding the
|
||||
* card would make it unplayable exactly where a district ends. Play showed the cost of that the
|
||||
* other way round: a Transmission Lines card went down at (-2,4) with the sign at column 3, which
|
||||
* reads at the table as building outside your own territory, and §8.1 and §10 both reason about
|
||||
* what is inside a player's Limits.
|
||||
*
|
||||
* THE FEARED CASE DID NOT ARISE, and was measured on the move that prompted the change rather than
|
||||
* argued: the Power Plant sat at (-1,3) against a sign at 3, and (-2,2) and (-2,3) were both free,
|
||||
* legal and inside. A Facility at the limit keeps six of its nine spots, and the Limits move outward
|
||||
* as the Running Track grows (§2.1, Gap 4a), so the ground for a Modifier arrives with the district.
|
||||
* `check` bars them from the Running Track ROW as well, which is the ground the main grows onto.
|
||||
*/
|
||||
export function withinLimits(area: OfficeArea, coord: GridCoord): boolean {
|
||||
return coord.col >= area.limitsWest.col && coord.col <= area.limitsEast.col;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
+87
-2
@@ -37,6 +37,7 @@ import {
|
||||
writeLobby,
|
||||
writeSessions,
|
||||
} from './persistence.ts';
|
||||
import { createClaimStore } from './claims.ts';
|
||||
import { createSession } from './session.ts';
|
||||
import type { GameSession, Push } from './session.ts';
|
||||
import {
|
||||
@@ -170,6 +171,15 @@ export function startServer(opts: ServerOptions): void {
|
||||
const games = opts.initialGames;
|
||||
const lobbies = opts.initialLobbies;
|
||||
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
|
||||
for (const [gameId, lobby] of lobbies) gameCodes.set(lobby.gameCode, gameId);
|
||||
|
||||
@@ -322,6 +332,17 @@ export function startServer(opts: ServerOptions): void {
|
||||
gameId,
|
||||
gameCode: codes.get(gameId) ?? null,
|
||||
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(),
|
||||
}));
|
||||
// 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;
|
||||
}
|
||||
|
||||
const match = /^\/api\/games\/([^/]+)(\/save)?$/.exec(url.pathname);
|
||||
const match = /^\/api\/games\/([^/]+)(\/save|\/claim)?$/.exec(url.pathname);
|
||||
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) {
|
||||
sendJson(res, 404, { error: 'no such route' });
|
||||
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);
|
||||
if (!session) {
|
||||
sendJson(res, 404, { error: 'no such game' });
|
||||
@@ -656,6 +711,36 @@ export function startServer(opts: ServerOptions): void {
|
||||
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").
|
||||
|
||||
+32
-6
@@ -137,6 +137,8 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
||||
region?: number;
|
||||
direction?: string;
|
||||
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;
|
||||
tip: string;
|
||||
@@ -515,13 +517,18 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
||||
const arrow = t.facing === 'w' ? '\u25c0' : '\u25b6';
|
||||
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}`;
|
||||
// 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 dir = t.direction === 'west' ? ' \u25c0 west' : t.direction === 'east' ? ' east \u25b6' : '';
|
||||
const stages =
|
||||
typeof t.stagesLeft === 'number'
|
||||
? ` \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)` : ''
|
||||
}${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
|
||||
@@ -682,7 +689,17 @@ export function officeSvg(
|
||||
const c0 = Math.min(...cols);
|
||||
const c1 = Math.max(...cols);
|
||||
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
|
||||
// and the district hangs beneath it, as the rules describe it.
|
||||
@@ -1218,7 +1235,9 @@ export function officeSvg(
|
||||
if (limits) {
|
||||
const edge = (x: number, side: string): string =>
|
||||
`<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>`;
|
||||
out += edge(px(limits.west) - PAD / 2, 'w') + edge(px(limits.east) + W + PAD / 2, 'e');
|
||||
}
|
||||
@@ -1267,9 +1286,12 @@ export const BOARD_CSS = `
|
||||
.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). Drawn faint: they are the ruler the
|
||||
train is measured against, not something to look at instead of the train. */
|
||||
.bs-region{stroke:#4a5361;stroke-width:1.2;stroke-dasharray:3 3}
|
||||
/* 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. */
|
||||
.bs-flag line{stroke:#9aa3b0;stroke-width:1.6}
|
||||
.bs-flag polygon{fill:#d2453f;stroke:#7d211d;stroke-width:0.8}
|
||||
@@ -1302,6 +1324,10 @@ export const BOARD_CSS = `
|
||||
.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-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}
|
||||
/* 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. */
|
||||
|
||||
+195
-18
@@ -20,6 +20,8 @@ import { adTrackCount, coordKey, seatOf, turnOf } 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 type { GameEvent } from '../engine/events.ts';
|
||||
import { badlyMadeUp } from '../engine/advance.ts';
|
||||
import type { CrewTray } from '../engine/state.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small formatters
|
||||
@@ -50,6 +52,23 @@ export function carLabel(c: RollingStock, homeSeat?: SeatIndex): string {
|
||||
return homeSeat !== undefined && c.origin === homeSeat ? `${label} (loaded here)` : label;
|
||||
}
|
||||
|
||||
/**
|
||||
* "a loaded boxcar", "an empty tank" — the article the word actually takes.
|
||||
*
|
||||
* The make-up line hard-coded "a" and produced "a empty tank" at the table. Vowel-initial is the
|
||||
* whole rule here: every car word is ordinary English ('empty', 'loaded', and the car types), so
|
||||
* there is no 'an hour' case to special-case and inventing one would be the more fragile choice.
|
||||
*/
|
||||
export function indefinite(label: string): string {
|
||||
return `${/^[aeiou]/i.test(label) ? 'an' : 'a'} ${label}`;
|
||||
}
|
||||
|
||||
/** "Train 10" / "Extra X18" — one spelling of a train's name for every line that mentions one. */
|
||||
export function trainLabel(trainNumber: number | null, isExtra: boolean): string {
|
||||
if (trainNumber === null) return 'the local crew';
|
||||
return isExtra ? `Extra X${trainNumber}` : `Train ${trainNumber}`;
|
||||
}
|
||||
|
||||
export function carsLabel(cars: RollingStock[]): string {
|
||||
if (cars.length === 0) return 'nothing';
|
||||
return cars.map(carLabel).join(', ');
|
||||
@@ -108,11 +127,22 @@ export type NarrateContext = {
|
||||
* events has no roster, and "Player 2" is a truthful fallback rather than a broken one.
|
||||
*/
|
||||
playerName?: (player: PlayerIndex) => string;
|
||||
/**
|
||||
* Names the Facility standing on one of a player's squares, or null where there is none.
|
||||
*
|
||||
* Switching lines used to give the bare coordinate — "Set out a loaded hopper at (-1,1)" — which
|
||||
* is the grid's own notation and means nothing at a table where people are looking at cards. The
|
||||
* industry is the whole point of the move, so it is what the line should say.
|
||||
*/
|
||||
facilityAt?: (player: PlayerIndex, at: GridCoord) => string | null;
|
||||
};
|
||||
|
||||
export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
const card = (id: string): string => ctx.cardName?.(id) ?? 'a card';
|
||||
const train = (id: TrayId): string => ctx.trainName?.(id) ?? String(id);
|
||||
// The industry on a square when there is one, and the coordinate when there is not — a crew works
|
||||
// plain track too, and "at nowhere" would be worse than the notation.
|
||||
const place = (player: PlayerIndex, c: GridCoord): string => ctx.facilityAt?.(player, c) ?? at(c);
|
||||
|
||||
switch (e.type) {
|
||||
// -- clock
|
||||
@@ -127,6 +157,18 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
.map((p) => ctx.playerName?.(p) ?? `Player ${p + 1}`)
|
||||
.join(' → ')}`,
|
||||
};
|
||||
case 'superintendentChanged':
|
||||
/**
|
||||
* The Fedora is the only thing in the game that changes hands on a clock rather than because
|
||||
* somebody did something, so it is the one handover nobody at the table watches happen.
|
||||
*/
|
||||
return {
|
||||
tone: 'clock',
|
||||
text:
|
||||
`SUPERINTENDENT — the Fedora passes to ${ctx.playerName?.(e.player) ?? 'the next player'} ` +
|
||||
`at the end of Stage ${e.stage}. They rule on clearances, take the Yard Office and Red Flag ` +
|
||||
`questions, and every round that goes round the table now starts with them.`,
|
||||
};
|
||||
case 'phaseBegan':
|
||||
// Its own tone, not `quiet`. A phase marker sat in the same grey as the events inside it, so
|
||||
// the log read as one undifferentiated column and you could not see where a phase began.
|
||||
@@ -155,7 +197,11 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
return {
|
||||
tone: 'plain',
|
||||
where: e.to,
|
||||
text: `Moved ${train(e.trayId)} ${at(e.from)} → ${at(e.to)}${e.via ? ` via ${at(e.via)}` : ''} — ${e.movesRemaining} of 6 Moves left. The crew chip on the grid carries the whole train with it.`,
|
||||
// NO TUTORIAL TAIL. "The crew chip on the grid carries the whole train with it" was appended
|
||||
// to EVERY move — six times a turn, and the opener (`localOpsOptionChosen`) already says it
|
||||
// once. It also pushed the useful half of the line out of the caption row, which shows one
|
||||
// step at a time and is the place a player reads a move as it happens.
|
||||
text: `Moved ${train(e.trayId)} ${at(e.from)} → ${place(e.player, e.to)}${e.via ? ` via ${at(e.via)}` : ''} — ${e.movesRemaining} of ${e.movesAllowed} Moves left`,
|
||||
};
|
||||
case 'carsCoupled': {
|
||||
/**
|
||||
@@ -167,22 +213,59 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
const own = e.recoupled?.stock.length ?? 0;
|
||||
const found = e.stock.length - own;
|
||||
const parts: string[] = [];
|
||||
if (own > 0) parts.push(`picked its own ${carsLabel(e.recoupled!.stock)} back up off ${at(e.recoupled!.at)} on the way out`);
|
||||
if (found > 0) parts.push(`coupled ${carsLabel(e.stock.slice(own))} standing on the line`);
|
||||
// `a loaded tank` for one, a bare list for several — "took loaded tank" reads as a telegram.
|
||||
const some = (cars: RollingStock[]): string =>
|
||||
cars.length === 1 ? indefinite(carLabel(cars[0]!)) : carsLabel(cars);
|
||||
if (own > 0) parts.push(`picked its own ${some(e.recoupled!.stock)} back up off ${at(e.recoupled!.at)} on the way out`);
|
||||
if (found > 0) parts.push(`took ${some(e.stock.slice(own))} standing there`);
|
||||
return {
|
||||
tone: 'plain',
|
||||
where: e.at,
|
||||
text:
|
||||
`Coupled ${e.stock.length} car(s) at ${at(e.at)} ${e.toNose ? 'ONTO THE NOSE' : 'behind the train'}` +
|
||||
// "1 car(s)" was the plural of a machine. The count is already implied by the cars named
|
||||
// in `parts`, so the sentence leads with where and which end instead.
|
||||
`Coupled at ${place(e.player, e.at)}, ${e.toNose ? 'ONTO THE NOSE' : 'behind the train'}` +
|
||||
` — ${parts.join(', and ')}`,
|
||||
};
|
||||
}
|
||||
case 'consistSorted':
|
||||
case 'consistSorted': {
|
||||
/**
|
||||
* WHERE THE ENGINE ENDED UP, and whether the train can still run.
|
||||
*
|
||||
* ASKED OF `badlyMadeUp` RATHER THAN RE-DECIDED HERE, which matters because the obvious guess
|
||||
* is wrong: §8.2 is enforced direction-free, so a train with its WHOLE consist ahead of the
|
||||
* engine is a pushing train and perfectly fit to leave. What it may not be is broken-backed,
|
||||
* with the engine buried among its own cars. A copy of that rule in the narrator would have
|
||||
* told a player their pushing train was stranded when it was not.
|
||||
*/
|
||||
const ahead = e.engineAt;
|
||||
const unfit = badlyMadeUp({ consist: e.after, engineAt: e.engineAt } as CrewTray);
|
||||
const where =
|
||||
ahead === 0
|
||||
? 'so the right car is now on the end and can be spotted'
|
||||
: unfit === null
|
||||
? `with the whole consist AHEAD of the engine — it runs as a pushing train`
|
||||
: `with ${ahead} car${ahead === 1 ? '' : 's'} ahead of the engine — ${unfit}, so it is held at the Office until it is sorted again (§8.2)`;
|
||||
return {
|
||||
tone: 'good',
|
||||
where: e.at,
|
||||
text: `Used the SMALL YARD — consist re-ordered from [${carsLabel(e.before)}] to [${carsLabel(e.after)}], so the right car is now on the end and can be spotted`,
|
||||
text: `Used the SMALL YARD — consist re-ordered from [${carsLabel(e.before)}] to [${carsLabel(e.after)}], ${where}`,
|
||||
};
|
||||
}
|
||||
case 'switchingEnded': {
|
||||
/**
|
||||
* The line that closes a switching turn, and the only one the history keeps from the middle of
|
||||
* it: what it cost, and where the crew was left standing.
|
||||
*/
|
||||
const used = `${e.movesUsed} of ${e.movesAllowed} Move${e.movesAllowed === 1 ? '' : 's'} used`;
|
||||
if (e.movesUsed === 0) return { tone: 'quiet', text: 'Finished switching without moving a car' };
|
||||
if (!e.lastMove) return { tone: 'plain', text: `Finished switching — ${used}` };
|
||||
return {
|
||||
tone: 'plain',
|
||||
where: e.lastMove.to,
|
||||
text: `Finished switching — ${used}, leaving ${train(e.lastMove.trayId)} at ${place(e.player, e.lastMove.to)}`,
|
||||
};
|
||||
}
|
||||
case 'carsDropped':
|
||||
// WHICH END. A cut comes off an outer end (§A.3) and the end decides everything that follows:
|
||||
// the train may pull away from cars set out behind it and must couple back up to cars set out
|
||||
@@ -191,7 +274,7 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
tone: 'plain',
|
||||
where: e.at,
|
||||
text:
|
||||
`Set out ${carsLabel(e.stock)} at ${at(e.at)}, off the ${e.fromNose ? 'NOSE — ahead of the engine, so pulling forward will couple them again' : 'TAIL — behind the engine, so it may pull away and leave them'}`,
|
||||
`Set out ${carsLabel(e.stock)} at ${place(e.player, e.at)}, off the ${e.fromNose ? 'NOSE — ahead of the engine, so pulling forward will couple them again' : 'TAIL — behind the engine, so it may pull away and leave them'}`,
|
||||
};
|
||||
|
||||
// -- cards
|
||||
@@ -343,12 +426,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
|
||||
* Mainline Phase begins (`expediteFault`).
|
||||
*/
|
||||
return {
|
||||
tone: 'plain',
|
||||
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.`
|
||||
: `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.`,
|
||||
};
|
||||
/**
|
||||
* WHOSE OFFICE, AND WHOSE TRAIN TO WORK — Jesse, playtest 2026-09-16.
|
||||
*
|
||||
* This said "ARRIVED at the Whistle Post" and then "You can work it in Cargo now". Both halves
|
||||
* 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':
|
||||
return {
|
||||
tone: 'bad',
|
||||
@@ -402,9 +498,39 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
};
|
||||
}
|
||||
case 'carPlacedOnTrain':
|
||||
return { tone: 'plain', text: `Added a ${carLabel(e.stock)} to the train being made up` };
|
||||
// NAMES THE TRAIN. "the train being made up" was true and useless: a player looking back for
|
||||
// what happened to train 10 found four lines that never said 10 (playtest, 2026-09-16).
|
||||
return {
|
||||
tone: 'plain',
|
||||
text: `Added ${indefinite(carLabel(e.stock))} to ${trainLabel(e.trainNumber, e.isExtra)}`,
|
||||
};
|
||||
case 'carPassed':
|
||||
return { tone: 'quiet', text: 'Passed — no suitable car in the Division Yard' };
|
||||
return {
|
||||
tone: 'quiet',
|
||||
text: `Passed on ${trainLabel(e.trainNumber, e.isExtra)} — no suitable car in the Division Yard`,
|
||||
};
|
||||
case 'makeUpShort': {
|
||||
// What it wanted, in the words the card uses, so the line can be checked against the card.
|
||||
const names: Record<'freight' | 'coach' | 'caboose', string> = {
|
||||
freight: 'freight car',
|
||||
coach: 'coach',
|
||||
caboose: 'caboose',
|
||||
};
|
||||
const wants = e.missing.map((m) => names[m]).join(' or ');
|
||||
const got = e.placed === 0 ? 'NO CARS AT ALL' : `only ${e.placed} of the ${e.wanted} its card calls for`;
|
||||
// §2.2 is the whole explanation and it is not guessable from the board: the cars are visible
|
||||
// in the Classification Yard, and why they will not come back is not.
|
||||
const why =
|
||||
e.waiting > 0
|
||||
? ` ${e.waiting} sit in the Classification Yard, which comes back only when the Division Yard is bare — and it still holds ${e.divisionYardHolds} cars.`
|
||||
: ' There are none in the Classification Yard either.';
|
||||
return {
|
||||
tone: 'bad',
|
||||
text:
|
||||
`${trainLabel(e.trainNumber, e.isExtra).toUpperCase()} WAS MADE UP WITH ${got} — the ` +
|
||||
`Division Yard holds no ${wants} it can take, so nobody was asked for one.${why}`,
|
||||
};
|
||||
}
|
||||
case 'dispatchBonusUsed':
|
||||
return {
|
||||
tone: 'good',
|
||||
@@ -422,12 +548,31 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
const wrecked = e.trains
|
||||
.map((t) => `${t.label} (${t.consist.length ? carsLabel(t.consist) : 'no cars'})`)
|
||||
.join(' and ');
|
||||
/**
|
||||
* WHOSE OFFICE, AND WHO PAYS (playtest, 2026-09-16: "it doesn't say who suffers the revenue
|
||||
* loss… we need to know which player received the penalty and why").
|
||||
*
|
||||
* `player` is the seat at fault, and for everything that happens inside a district that is the
|
||||
* district's owner — so it names the place as well as the payer. A Mainline collision is the
|
||||
* Superintendent's by rule (§10), which is a different sentence: it happened on open road, not
|
||||
* in anybody's Office. The 5 points ride in a separate `revenueChanged`, which is why the line
|
||||
* never mentioned them; a player should not have to add two log entries together.
|
||||
*/
|
||||
const who = ctx.playerName?.(e.player) ?? null;
|
||||
const mainline = e.where === 'the Mainline';
|
||||
const place = who === null || mainline ? e.where : `${who}'s ${e.where.replace(/^the /, '')}`;
|
||||
const cost =
|
||||
who === null
|
||||
? ' 5 Revenue is lost.'
|
||||
: mainline
|
||||
? ` ${who} loses 5 Revenue: §10 makes a Mainline collision the Superintendent's fault.`
|
||||
: ` ${who} loses 5 Revenue — it happened in their district.`;
|
||||
return {
|
||||
tone: 'bad',
|
||||
text:
|
||||
`COLLISION — ${wrecked} destroyed: ${why}. Engines and cabooses go back to the Division ` +
|
||||
`Yard, all other cars to the Classification Yard. A Timetabled train card returns ` +
|
||||
`to its slot and runs again next Day; an Extra is gone for good.`,
|
||||
`COLLISION at ${place} — ${wrecked} destroyed: ${why}.${cost} Engines and cabooses go back ` +
|
||||
`to the Division Yard, all other cars to the Classification Yard. A Timetabled train card ` +
|
||||
`returns to its slot and runs again next Day; an Extra is gone for good.`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -647,6 +792,38 @@ export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[]
|
||||
* that actually refused rather than a second guess at it.
|
||||
*/
|
||||
if (f.kind === 'passenger') {
|
||||
/**
|
||||
* NOBODY TO PUT ON THE PLATFORM, AND NO WAY TO SEE WHY (playtest, 2026-09-16).
|
||||
*
|
||||
* "He would like to have two passengers waiting in his depot… but the only option he had was
|
||||
* bringing a tank load into the refinery." His Depot had a Restaurant and a Hotel beside it and
|
||||
* three outbound slots — capacity was never the problem. §6.3 stocking takes a LOADED car of
|
||||
* the facility's type out of the Division Yard, and there was not a loaded coach in it: six
|
||||
* were sitting in Classification, which §2.2 returns only when the Division Yard runs bare.
|
||||
*
|
||||
* Jesse's ruling (2026-09-16) is the same one Gitea#2 got: the shortage stays, because running
|
||||
* out is part of the game. What must not stay is the silence — an action with no legal target
|
||||
* is simply absent from the menu, so the player is left to guess whether they misunderstood the
|
||||
* rules or the game is broken.
|
||||
*/
|
||||
if (f.allows.outbound && f.outboundBox.length < f.capacity.outbound) {
|
||||
const loadedCoaches = s.yards.divisionYard.filter((c) => c.type === 'coach' && c.loaded).length;
|
||||
if (loadedCoaches === 0) {
|
||||
const waiting = s.yards.classificationYard.filter((c) => c.type === 'coach' && c.loaded).length;
|
||||
out.push({
|
||||
where: `${name} ${key}`,
|
||||
why:
|
||||
`room for ${f.capacity.outbound - f.outboundBox.length} more passenger` +
|
||||
`${f.capacity.outbound - f.outboundBox.length === 1 ? '' : 's'} to wait, but no loaded ` +
|
||||
`coach in the Division Yard for the Freight Agent to bring over` +
|
||||
(waiting > 0
|
||||
? ` — ${waiting} ${waiting === 1 ? 'is' : 'are'} in the Classification Yard, which comes ` +
|
||||
'back only when the Division Yard is bare'
|
||||
: ''),
|
||||
severity: 'waiting',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (portersLeft(f) > 0) {
|
||||
const coord = uncoordKey(key);
|
||||
// Passengers standing on the platform with nothing carrying them away.
|
||||
|
||||
@@ -79,6 +79,8 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
|
||||
const narrateCtx = {
|
||||
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 `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.
|
||||
|
||||
+21
-3
@@ -55,7 +55,7 @@ export function turnChartHtml(f: TurnChartFrame, actorName: string | null, super
|
||||
// Says WHO builds, which is the question this phase actually raises at a table: the round
|
||||
// starts with the Superintendent and works left, one car each, repeating (§7, Gap 9) — not
|
||||
// with whoever played the card. An Extra is the exception: its player loads it as they choose.
|
||||
tip: 'Timetabled trains for this Stage are built: starting with the Superintendent and working left, each player adds ONE car, going round again until the consist is full or the Division Yard has nothing suitable. New timetabled trains are rolled onto the timetable. Held trains are built. An Extra is loaded by the player who played it.',
|
||||
tip: 'Timetabled trains for this Stage are built: each player adds ONE car at a time, starting with the Superintendent and working eastward, repeating until the consist is full or the Division Yard has nothing suitable. New timetabled trains are rolled onto the timetable. Held trains are built. An Extra is loaded by the player who played it.',
|
||||
// a locomotive being made up
|
||||
icon: '<rect class="ic" x="2" y="6" width="9" height="7" rx="1"/><path class="ic" d="M11 9h4v4h-4"/><circle class="icf" cx="5" cy="15" r="1.5"/><circle class="icf" cx="13" cy="15" r="1.5"/>',
|
||||
},
|
||||
@@ -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
|
||||
* 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
|
||||
? ` <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>` +
|
||||
`<span class="dim">${esc(f.clock)}</span></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
|
||||
// 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
|
||||
|
||||
+93
-3
@@ -10,12 +10,13 @@
|
||||
* drift into two different pictures of the same board.
|
||||
*/
|
||||
|
||||
import { isExpedited, regionOfTransit } from '../engine/advance.ts';
|
||||
import { badlyMadeUp, isExpedited, regionOfTransit } from '../engine/advance.ts';
|
||||
import {
|
||||
areaAtSeat,
|
||||
areaOf,
|
||||
destinationsFor,
|
||||
facilityCarType,
|
||||
isBeingMadeUp,
|
||||
laborersLeft,
|
||||
movesFor,
|
||||
ownCutFor,
|
||||
@@ -276,6 +277,15 @@ export type TrainChip = {
|
||||
* it belongs in the tooltip, where there is room to say which it is.
|
||||
*/
|
||||
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.
|
||||
@@ -720,6 +730,26 @@ function suppressedGrants(modifiers: string[], f: Facility): string[] {
|
||||
for (const key of modifiers) {
|
||||
const m = MODIFIER_PROFILES.find((p) => p.kind === key);
|
||||
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) {
|
||||
out.push(`${m.name}: +${m.addOut} outbound has no effect here — this facility only receives`);
|
||||
}
|
||||
@@ -1006,8 +1036,65 @@ export function describeIntent(s: GameState, i: Intent): string {
|
||||
const end = i.fromNose ? 'off the front' : 'off the back';
|
||||
return `set out ${carsLabel(cut)} ${end}`;
|
||||
}
|
||||
case 'switch.sortConsist':
|
||||
return `re-order consist [${i.order.join(',')}]`;
|
||||
case 'switch.sortConsist': {
|
||||
/**
|
||||
* THE TRAIN IT WOULD MAKE, DRAWN THE WAY THE BOARD DRAWS IT.
|
||||
*
|
||||
* This read `re-order consist [1,2,3,0]` — the engine's own array indices offered to a person
|
||||
* — and the option Jesse wanted was the first of five and unidentifiable (playtest,
|
||||
* 2026-09-17). Naming the cars fixed that and left a second ambiguity he caught immediately:
|
||||
* a list "front to back" means nothing at a table looking at a map, because which end is the
|
||||
* front depends on which way the train is pointed.
|
||||
*
|
||||
* SO IT IS LAID OUT WEST TO EAST, exactly as `board-svg.ts` lays the crew strip: the consist
|
||||
* is stored nose first, and a train facing EAST is reversed so its nose lands at the east end
|
||||
* where it actually is. The engine is the same ◀ / ▶ arrow the board uses, seated where it
|
||||
* will be, so "ahead of the engine" and "behind the engine" are read off the picture rather
|
||||
* than asserted in words — and the button and the board cannot disagree.
|
||||
*/
|
||||
const sorting = s.trays.get(i.trayId);
|
||||
if (!sorting) return `re-order consist [${i.order.join(',')}]`;
|
||||
const after = i.order.map((n) => sorting.consist[n]!).filter((c) => c !== undefined);
|
||||
const engineAt = i.engineAt ?? 0;
|
||||
const facing = railFacingOf(sorting);
|
||||
|
||||
const items = after.map((c) => carLabel(c));
|
||||
items.splice(engineAt, 0, facing === 'w' ? '◀ ENGINE' : 'ENGINE ▶');
|
||||
// West on the left, like the map and like the crew strip on the board.
|
||||
const strip = (facing === 'e' ? [...items].reverse() : items).join(' · ');
|
||||
|
||||
/**
|
||||
* WHAT IT WOULD MEAN, from §8.2's own predicate rather than a copy of it: a train with its
|
||||
* whole consist ahead of the engine is a PUSHING train and fit to run, a buried engine is not,
|
||||
* and a caboose has to ride at the end away from the engine. Numbered trains only — a local
|
||||
* crew has no card and never departs, so a departure verdict on one is noise.
|
||||
*/
|
||||
const unfit =
|
||||
sorting.trainNumber === null
|
||||
? null
|
||||
: badlyMadeUp({ ...sorting, consist: after, engineAt });
|
||||
// `badlyMadeUp` leads with "not made up — ", which reads as a stutter in front of HELD. The
|
||||
// reason after it is the part worth showing, so the prefix comes off.
|
||||
const because = unfit?.replace(/^not made up — /, '') ?? '';
|
||||
const verdict =
|
||||
sorting.trainNumber === null
|
||||
? ''
|
||||
: unfit === null
|
||||
? ' · MADE UP, ready to leave'
|
||||
: ` · HELD at the Office: ${because}`;
|
||||
|
||||
// A sort that only moves the engine says which errand it is running, rather than reprinting a
|
||||
// car order that has not changed.
|
||||
const sameOrder = i.order.every((n, at) => n === at);
|
||||
const lead = sameOrder
|
||||
? engineAt === 0
|
||||
? 'pull the engine back to the front'
|
||||
: engineAt === after.length
|
||||
? 'put the whole consist ahead of the engine'
|
||||
: `move the engine behind ${engineAt} car${engineAt === 1 ? '' : 's'}`
|
||||
: 're-order';
|
||||
return `${lead} — west to east: ${strip}${verdict}`;
|
||||
}
|
||||
case 'freightAgent.stockOutbound': {
|
||||
/**
|
||||
* "stock a coach at (0, 0)" reads as putting a CAR on the track, and was reported as exactly
|
||||
@@ -2367,6 +2454,9 @@ function trainChip(s: GameState, id: string): TrainChip {
|
||||
engineAt: at,
|
||||
facing: railFacingOf(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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+143
-2
@@ -29,7 +29,7 @@ import type { GameEvent } from '../engine/events.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import { legalActions } from '../engine/legal.ts';
|
||||
import { createGame } from '../engine/setup.ts';
|
||||
import type { CardId, GameConfig, GameState, PlayerIndex } from '../engine/state.ts';
|
||||
import type { CardId, GameConfig, GameState, GridCoord, PlayerIndex } from '../engine/state.ts';
|
||||
import { overHandLimit as overHandLimitOf } from '../engine/state.ts';
|
||||
import { playerAtSeat } from '../engine/state.ts';
|
||||
import { cuesFor, narrate } from '../sim/narrate.ts';
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
LEGACY_HOUSE_RULES,
|
||||
collectiveRevenueFloor,
|
||||
houseRules,
|
||||
industryProfile,
|
||||
mainlineProfile,
|
||||
trainProfile,
|
||||
} from '../engine/content.ts';
|
||||
@@ -663,6 +664,14 @@ export type Menu = {
|
||||
makeUp: {
|
||||
trayId: 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[];
|
||||
pass: number | null;
|
||||
/**
|
||||
@@ -803,6 +812,7 @@ export function actionMenu(game: Game, seat: PlayerIndex = 0): Menu {
|
||||
? {
|
||||
trayId: filling,
|
||||
title: consistTitle(game, filling) ?? 'Making up the train',
|
||||
needs: consistNeeds(game, filling),
|
||||
cars: makeUpCars,
|
||||
pass,
|
||||
advice: makeUpAdvice(game, filling, makeUpCars),
|
||||
@@ -955,6 +965,40 @@ function consistTitle(game: Game, trayId: string): string | null {
|
||||
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…"
|
||||
*
|
||||
@@ -1226,6 +1270,81 @@ function uncapitalise(text: string): string {
|
||||
return /^[A-Z][a-z]/.test(text) ? text.charAt(0).toLowerCase() + text.slice(1) : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Facility on one of a player's squares, or null — for naming the place a switching line is
|
||||
* about. Shared by the narrator and the filter below, so both agree on what counts as an industry.
|
||||
*/
|
||||
function facilityOn(game: Game, player: PlayerIndex, at: GridCoord): string | null {
|
||||
const card = areaOf(game.state, player).grid.get(`${at.row},${at.col}`);
|
||||
const f = card?.facility;
|
||||
// The name printed on the card, not the internal key: `industryProfile` is the one place that
|
||||
// knows "grocersWarehouse" reads as "Grocer's Warehouse".
|
||||
if (f) return f.subtype === 'office' ? 'the Office' : `the ${industryProfile(f.subtype).name}`;
|
||||
/**
|
||||
* A SMALL YARD IS A PLACE TOO, though it is an enhancement on a plain card rather than a Facility.
|
||||
*
|
||||
* It is the one square in a district a crew goes to ON PURPOSE without working an industry — the
|
||||
* whole point of the trip is to arrive there and re-make the train — so "leaving Train 10 at
|
||||
* (-1,1)" was the one line most in need of a name. Only this enhancement: the others change what a
|
||||
* square DOES without being somewhere a player aims a crew at.
|
||||
*/
|
||||
return card?.enhancements.includes('smallYard') ? 'the Small Yard' : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* HOW MUCH SWITCHING REACHES THE HISTORY PANEL (Jesse, 2026-09-17).
|
||||
*
|
||||
* All of it did. A six-Move turn wrote a line per move — "Moved Train 10 (0,3) → (-1,-3) — 4 of 6
|
||||
* Moves left" — plus one per mandatory coupling, so two players shunting filled the panel with
|
||||
* coordinates and pushed everything else off the top. His ruling, asked as a question from the
|
||||
* table: a line saying somebody switched, the cars they set out at or picked up from an INDUSTRY,
|
||||
* and the Small Yard sort. Not every move, and not every coupling.
|
||||
*
|
||||
* WHAT STAYS, and why each one earns its line: `localOpsOptionChosen` already says who is switching
|
||||
* and is left alone; work at an industry is the point of switching and changes what can be loaded
|
||||
* next; and `consistSorted` spends a Move and changes what the train can do. A plain move along
|
||||
* one's own track changes nothing anybody needs to read back.
|
||||
*
|
||||
* THE LINE IS STILL WRITTEN, MARKED `trace`, AND THAT IS NOT A DETAIL. Dropping these events on the
|
||||
* floor was the first attempt and the suite caught it: `dwellForStep` gives a step NO dwell when it
|
||||
* produced no narration, so a switching move with no line became a silent step and the board stopped
|
||||
* replaying switching altogether — it would have snapped through the very thing v0.8.0 was built to
|
||||
* let the table watch. The line still rides with its display step and still captions the board as
|
||||
* the move goes up; only the history panel skips it.
|
||||
*
|
||||
* THE REPLAY VIEWER IS UNAFFECTED for the same reason, and it renders through `narrate` directly.
|
||||
*/
|
||||
function inHistory(game: Game, e: GameEvent): boolean {
|
||||
const industry = (player: PlayerIndex, ...coords: GridCoord[]): boolean =>
|
||||
coords.some((c) => facilityOn(game, player, c) !== null);
|
||||
switch (e.type) {
|
||||
/**
|
||||
* THE FIRST MOVE OF A TURN IS KEPT (Jesse, 2026-09-17: "also keep the first and last move").
|
||||
*
|
||||
* It says a crew set off and from where, which is the half of "somebody switched" that the
|
||||
* opener does not carry. The LAST move cannot be kept the same way — nothing knows a move was
|
||||
* the last until the turn is over, and by then the line has already been written and streamed to
|
||||
* every client (`server/session.ts` § linesSince), so it cannot be revised. `switchingEnded`
|
||||
* carries it instead, as the line that closes the turn.
|
||||
*
|
||||
* Recognised by the MOVE COUNT rather than by tracking state: the first move of a turn is the
|
||||
* one that leaves `movesAllowed - 1` behind it, which the event now carries so this holds on a
|
||||
* five-Move night Stage too.
|
||||
*/
|
||||
case 'trayMoved':
|
||||
return e.movesRemaining === e.movesAllowed - 1;
|
||||
// Coupling is mandatory when a crew runs over cars (§A.4), so most of these happen to a player
|
||||
// rather than being chosen. The ones worth reading are where cars left or joined an industry —
|
||||
// `from` names the cards the cars were actually lifted off, which is where they had been spotted.
|
||||
case 'carsCoupled':
|
||||
return industry(e.player, e.at, ...e.from);
|
||||
case 'carsDropped':
|
||||
return industry(e.player, e.at);
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = null): void {
|
||||
const who = actor === null ? null : (game.state.players[actor]?.name ?? null);
|
||||
for (const e of events) {
|
||||
@@ -1234,6 +1353,14 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
|
||||
const n = narrate(e, {
|
||||
cardName: (id) => cardName(game.state, id),
|
||||
trainName: (id) => trainName(game.state, id),
|
||||
facilityAt: (player, at) => facilityOn(game, player, at),
|
||||
// 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).
|
||||
@@ -1270,7 +1397,9 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
|
||||
: mine
|
||||
? `Player ${who} ${uncapitalise(said)}`
|
||||
: said;
|
||||
game.log.push({ text, tone: mine || ruling ? 'act' : n.tone });
|
||||
// `trace` is a tone the history panel does not draw — see `inHistory`. The line exists so the
|
||||
// step that caused it has narration to caption the board with, and a dwell to be watched for.
|
||||
game.log.push({ text, tone: inHistory(game, e) ? (mine || ruling ? 'act' : n.tone) : 'trace' });
|
||||
|
||||
}
|
||||
game.cues.push(...cuesFor(events));
|
||||
@@ -1287,6 +1416,18 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
|
||||
`Train ${e.isExtra ? 'X' : ''}${e.trainNumber} has completed its run, leaving via the ` +
|
||||
`${e.side === 'east' ? 'Eastern' : 'Western'} Division Point. All players get 1 Revenue.`;
|
||||
}
|
||||
/**
|
||||
* THE FEDORA MOVING IS ANNOUNCED, NOT JUST LOGGED (playtest, 2026-09-16).
|
||||
*
|
||||
* It is the one thing in the game that changes hands on the clock rather than because somebody
|
||||
* did something, so nobody is watching for it — and it decides who rules on clearances and who
|
||||
* every round starts with. A line in the history is where you find it afterwards; this is what
|
||||
* tells the table as it happens, the same treatment a completed run already gets.
|
||||
*/
|
||||
if (e.type === 'superintendentChanged') {
|
||||
const name = game.state.players[e.player]?.name ?? 'the next player';
|
||||
game.announced = `${name} is now the Superintendent — the Fedora passed at the end of Stage ${e.stage}.`;
|
||||
}
|
||||
}
|
||||
// Keep the log bounded; the full history lives in `history` and can be replayed.
|
||||
if (game.log.length > 400) game.log.splice(0, game.log.length - 400);
|
||||
|
||||
+2
-1
@@ -107,7 +107,8 @@ function explain(code: unknown, fallback: string): string {
|
||||
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, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
+359
-24
@@ -27,7 +27,7 @@ import type { PlayerIndex } from '../engine/state.ts';
|
||||
import type { PublicDistrict } from '../sim/view.ts';
|
||||
import { actorOnScreen, createStepQueue } from './step-queue.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 {
|
||||
closestPreset,
|
||||
@@ -231,7 +231,9 @@ function renderWatching(f?: Frame): void {
|
||||
return;
|
||||
}
|
||||
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.
|
||||
*
|
||||
@@ -273,6 +275,56 @@ function renderWatching(f?: Frame): void {
|
||||
$('watching-skip').onclick = () => {
|
||||
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 {
|
||||
@@ -285,6 +337,18 @@ function watchedDistrict(f: Frame): PublicDistrict | null {
|
||||
* 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.
|
||||
*/
|
||||
/**
|
||||
* A deliberate look wins over whoever happens to be acting, for this one render (see `peekPlayer`)
|
||||
* — INCLUDING A LOOK AT YOUR OWN BOARD, which is why this returns rather than falling through.
|
||||
*
|
||||
* Falling through sent "show me mine" to the actor logic below, so the one player who could not
|
||||
* reach their own Office Area was the player waiting on everybody else (playtest, 2026-09-16: Tom,
|
||||
* hanging about while the board followed Jesse). Null IS your own district: it is what the caller
|
||||
* draws from `f.cells` when nobody else is being watched.
|
||||
*/
|
||||
if (peekPlayer !== null) {
|
||||
return peekPlayer === f.viewer ? null : (pub.districts.find((d) => d.player === peekPlayer) ?? null);
|
||||
}
|
||||
let player: PlayerIndex | null = f.actor;
|
||||
if (stepQueue.busy()) {
|
||||
const acting = stepQueue.showing()?.player;
|
||||
@@ -430,6 +494,22 @@ let lastDay: number | null = null;
|
||||
* ones. Cleared whenever the named crew stops being one of the choices.
|
||||
*/
|
||||
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']);
|
||||
|
||||
/** The crew whose squares the board is drawing: the chosen one, or the only one there is. */
|
||||
@@ -496,11 +576,17 @@ function renderTurnChart(f: Frame): void {
|
||||
// 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
|
||||
// chip that can never change is a chip to read past.
|
||||
const superName =
|
||||
f.players.length > 1 ? (f.players.find((p) => p.index === f.superintendent)?.name ?? null) : null;
|
||||
$('turnchart').innerHTML = turnChartHtml(replaying ? { ...f, awaiting: null } : f, actorName, superName);
|
||||
f.players.length > 1 ? (f.players.find((p) => p.index === table.superintendent)?.name ?? null) : null;
|
||||
$('turnchart').innerHTML = turnChartHtml(
|
||||
replaying ? { ...f, ...table, awaiting: null } : f,
|
||||
actorName,
|
||||
superName,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1048,9 +1134,66 @@ function abandonRemote(): void {
|
||||
* 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.
|
||||
*/
|
||||
/**
|
||||
* 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 {
|
||||
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.
|
||||
*
|
||||
@@ -1614,8 +1757,17 @@ function render(): void {
|
||||
$('depts').classList.remove('aiming');
|
||||
}
|
||||
|
||||
// -- making up a train: the Division Yard chip that shows the car IS the button.
|
||||
if (menu.makeUp) {
|
||||
/**
|
||||
* -- making up a train: the Division Yard chip that shows the car IS the button.
|
||||
*
|
||||
* NOT WHILE THE BOARD IS BEHIND (playtest, 2026-09-16). `renderActions` puts the action list away
|
||||
* while the queue is catching up — a move offered against a position that has already moved on is
|
||||
* a move made blind — but this wiring sat outside that guard, so the yard chips stayed lit and
|
||||
* clickable. Jesse clicked one during a bot's make-up, a coach left the yard, and the train ended
|
||||
* up with three cars: he had submitted a real intent against a board he could not see. The chips
|
||||
* follow the same rule as every other control now.
|
||||
*/
|
||||
if (menu.makeUp && !stepQueue.busy()) {
|
||||
for (const el of Array.from($('divyard').querySelectorAll('[data-car]'))) {
|
||||
const node = el as HTMLElement;
|
||||
const car = menu.makeUp!.cars.find(
|
||||
@@ -1655,8 +1807,27 @@ function render(): void {
|
||||
* 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);
|
||||
/**
|
||||
* HELD BACK FIRST, THEN THE TRACE LINES DROPPED — the order matters.
|
||||
*
|
||||
* `pendingLines` counts lines in the log, including the `trace` ones a switching move writes for
|
||||
* its caption (`web/game.ts` § inHistory), so the tail has to be cut off the RAW list or the
|
||||
* arithmetic slips and the panel runs ahead of the board. Filtering afterwards only decides what
|
||||
* is drawn.
|
||||
*/
|
||||
const allLines = (heldBack > 0 ? session.lines().slice(0, -heldBack) : session.lines()).filter(
|
||||
(l) => l.tone !== 'trace',
|
||||
);
|
||||
/**
|
||||
* NINETY LINES, IN THE SAME BOX (Jesse, 2026-09-16: "increase to 90, keep the box the same size").
|
||||
*
|
||||
* The panel scrolls already, so a longer tail costs no screen and lets a player scroll further
|
||||
* back through a Stage they were not watching. It is capped at all only because the list is
|
||||
* rebuilt on every render; the log itself is uncapped in memory, so the number is a display
|
||||
* choice rather than a limit. The BOX stays 230px on purpose — growing it would push the newest
|
||||
* line, the one being read, further from where the eye already is.
|
||||
*/
|
||||
const shownLines = allLines.slice(-90);
|
||||
/**
|
||||
* WHERE THE GAME BEGAN. In a multiplayer game the bots move the instant the host presses Start, so
|
||||
* by the time the board paints the log already has several turns in it and nothing says which of
|
||||
@@ -1779,31 +1950,81 @@ function renderUndo(): void {
|
||||
* left, and the moment the Division Yard empties a whole pile comes back at once.
|
||||
*/
|
||||
function renderYards(f: Frame): void {
|
||||
$('divyard').innerHTML = yardHtml(f.yards.division);
|
||||
$('clsyard').innerHTML = yardHtml(f.yards.classification);
|
||||
$('divtot').textContent = `${f.yards.divisionTotal} cars`;
|
||||
$('clstot').textContent = `${f.yards.classificationTotal} cars`;
|
||||
/**
|
||||
* THE YARDS BELONG TO THE BOARD ON SCREEN, NOT TO THE GAME (playtest, 2026-09-16).
|
||||
*
|
||||
* They were drawn from the live Frame while everything around them was held back, so a player
|
||||
* five moves behind read yard counts from a future they had not been shown — "the yards may not
|
||||
* be in sync with the turns behind", and they were not. Same rule as the turn chart: while the
|
||||
* queue is behind, this is the shown board's yards; at rest the two are the same object.
|
||||
*/
|
||||
const pub = stepQueue.current();
|
||||
const yards = pub && stepQueue.busy() ? pub.yards : f.yards;
|
||||
$('divyard').innerHTML = yardHtml(yards.division);
|
||||
$('clsyard').innerHTML = yardHtml(yards.classification);
|
||||
$('divtot').textContent = `${yards.divisionTotal} cars`;
|
||||
$('clstot').textContent = `${yards.classificationTotal} cars`;
|
||||
|
||||
// The one thing worth calling out: the yard about to turn over.
|
||||
const bare = f.yards.divisionTotal === 0;
|
||||
/**
|
||||
* The one thing worth calling out: the yard about to turn over — or the one that never will.
|
||||
*
|
||||
* THE COACH CASE IS ITS OWN WARNING (Jesse's ruling, 2026-09-17: say it loudly, change no rule).
|
||||
* Coaches travel one way. §9.2 boarding discards the emptied coach into the CLASSIFICATION Yard
|
||||
* and detraining draws a fresh empty out of the Division Yard, and §2.2 sends Classification back
|
||||
* only when the Division Yard runs bare. Measured over one three-Day game: sixteen coaches at
|
||||
* setup, none left by Day 2 Stage 8, fifteen piled in Classification, and the Division Yard
|
||||
* holding steady at 46-47 freight cars — so the refill will not fire before the game ends.
|
||||
*
|
||||
* Every consequence of that is silent on the board. A Depot simply offers no passenger action, and
|
||||
* the Sparrow — three coaches and nothing else — is made up empty and runs the whole Division that
|
||||
* way. The cars are in plain sight in Classification; what cannot be seen is that they are not
|
||||
* coming back. So the panel says it where the pile is.
|
||||
*/
|
||||
const held = (row: { type: string; loaded: number; empty: number }[], type: string): number => {
|
||||
const r = row.find((c) => c.type === type);
|
||||
return r ? r.loaded + r.empty : 0;
|
||||
};
|
||||
const bare = yards.divisionTotal === 0;
|
||||
const coachesStranded = !bare && held(yards.division, 'coach') === 0 && held(yards.classification, 'coach') > 0;
|
||||
$('divyard').classList.toggle('bare', bare);
|
||||
$('yardnote').textContent = bare
|
||||
? `The Division Yard is bare — the ${f.yards.classificationTotal} cars in Classification return to it now.`
|
||||
: 'loaded / empty. Classification returns to the Division Yard only when the Division Yard is bare.';
|
||||
? `The Division Yard is bare — the ${yards.classificationTotal} cars in Classification return to it now.`
|
||||
: coachesStranded
|
||||
? `NO COACHES in the Division Yard — all ${held(yards.classification, 'coach')} are in Classification, ` +
|
||||
`which comes back only when the Division Yard is bare, and it still holds ${yards.divisionTotal} cars. ` +
|
||||
'Until then no passengers can board or detrain, and a train whose card calls for coaches is made up empty.'
|
||||
: 'loaded / empty. Classification returns to the Division Yard only when the Division Yard is bare.';
|
||||
}
|
||||
|
||||
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');
|
||||
if (open) sec.classList.remove('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
|
||||
// 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 =
|
||||
`${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` : '');
|
||||
|
||||
/**
|
||||
@@ -1835,6 +2056,55 @@ function renderDistrict(f: Frame): void {
|
||||
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');
|
||||
/**
|
||||
* EVERY SEAT, YOURS INCLUDED, IN MAP ORDER (playtest, 2026-09-16).
|
||||
*
|
||||
* Two faults, both reported from one game. There was no button for your OWN district, so a player
|
||||
* waiting on everybody else could look at any board except the one they were playing — and the
|
||||
* buttons came out in player order, which is the order people joined, not the order they sit.
|
||||
*
|
||||
* Sorted by SEAT, which is west-to-east along the Division exactly as the map draws it, so the row
|
||||
* reads left to right the way the railroad does. Seat is not player index and must not be assumed
|
||||
* to be: under Employee Rotation the seating moves, and because this sorts the Frame's own `seat`
|
||||
* on every render, the buttons rotate with the players rather than having to be told.
|
||||
*/
|
||||
const seats = [...f.players].sort((a, b) => a.seat - b.seat);
|
||||
peek.innerHTML = '';
|
||||
peek.hidden = seats.length < 2;
|
||||
const watchedNow = watchedDistrict(f);
|
||||
for (const p of seats) {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'ghost';
|
||||
// NAMES GO IN AS TEXT, NEVER MARKUP: a display name is whatever somebody typed in the lobby.
|
||||
b.textContent = p.name;
|
||||
const isYou = p.index === f.viewer;
|
||||
// Which board is up right now — yours when nothing is being watched, otherwise the watched one.
|
||||
const showing = watchedNow === null ? f.viewer : watchedNow.player;
|
||||
// `.seg button[aria-pressed="true"]` already lights the current one — no extra class to style.
|
||||
b.setAttribute('aria-pressed', String(p.index === showing));
|
||||
b.title = isYou
|
||||
? 'Back to your own Office Area.'
|
||||
: `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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2171,11 +2441,34 @@ function renderActions(
|
||||
* title of the ONE group `menu.makeUp` actually covers leaves every other "Making up …" group,
|
||||
* however it is titled, on screen where a player can act on it.
|
||||
*/
|
||||
/**
|
||||
* WHAT A ROLE IS FOR, said where the role is chosen (playtest, 2026-09-17).
|
||||
*
|
||||
* Tom had a train standing at his Depot with a loaded coach on it, picked the Freight Agent to
|
||||
* unload the passengers, and found nothing that would do it — "didn't have any opportunity to
|
||||
* unload passengers". Both halves of that were working correctly and neither was visible: the
|
||||
* Freight Agent stocks a box and never touches a train, and detraining is a PORTER's action in
|
||||
* the Cargo phase. Why it was impossible as well (§9.2 wants an empty coach from the Division
|
||||
* Yard) was already in the Blocked panel, off to the side — but a player who has chosen the
|
||||
* wrong role is not reading a panel about the right one.
|
||||
*
|
||||
* A SENTENCE, NOT A HOVER. The tip on the heading would have answered him only if he already
|
||||
* suspected he was in the wrong place, which is precisely what he did not.
|
||||
*/
|
||||
const ROLE_SCOPE: Record<string, string> = {
|
||||
'freightAgent.':
|
||||
'The Freight Agent stocks the green Loading box from the Division Yard, and frees a jam. ' +
|
||||
'Passengers board and detrain in the Cargo phase, with a Porter — not here.',
|
||||
'porter.': 'A Porter works passengers between a platform and a coach standing at your Office.',
|
||||
'laborer.': 'A Laborer moves one load one step along MEN → AT → WORK, and loads or unloads a spotted car.',
|
||||
};
|
||||
|
||||
html += menu.direct
|
||||
.filter((g) => !/^(Play|Discard) a card from my hand$/.test(g.title) && g.title !== menu.makeUp?.title)
|
||||
.map(
|
||||
(g) =>
|
||||
`<div class="grp"><h3>${esc(g.title)}</h3>` +
|
||||
(ROLE_SCOPE[g.kind] ? `<p class="dim scope">${esc(ROLE_SCOPE[g.kind]!)}</p>` : '') +
|
||||
g.actions
|
||||
.map((a) => {
|
||||
// §6.2 — a drawn card has to be played or discarded before the turn can end. Keyed on
|
||||
@@ -2203,9 +2496,31 @@ function renderActions(
|
||||
const addable = menu.makeUp.cars.length;
|
||||
html +=
|
||||
`<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">` +
|
||||
(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>Each player adds one car at a time</b>, starting from the Superintendent and working ` +
|
||||
`eastward, repeating until the train is full or the Division Yard holds nothing it can take.`
|
||||
: menu.makeUp.pass !== null
|
||||
? '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.') +
|
||||
@@ -2349,9 +2664,27 @@ function writeFile(name: string, data: string): void {
|
||||
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();
|
||||
const stamp = `day${f.day}-stage${f.stage}`;
|
||||
/**
|
||||
* 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
|
||||
@@ -2363,14 +2696,16 @@ async function downloadSave(): Promise<void> {
|
||||
const res = await fetch(`/api/save?token=${encodeURIComponent(remoteToken)}`);
|
||||
if (!res.ok) return;
|
||||
const body = (await res.json()) as { save: unknown };
|
||||
writeFile(`station-master-${stamp}.json`, JSON.stringify(body.save, null, 1));
|
||||
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;
|
||||
}
|
||||
writeFile(`station-master-seed${session.seed()}-${stamp}.json`, JSON.stringify(session.save(), null, 1));
|
||||
const solo = gameCode === '' ? `station-master-seed${session.seed()}` : gameCode.toLowerCase();
|
||||
writeFile(saveFileName(solo, f), JSON.stringify(session.save(), null, 1));
|
||||
}
|
||||
|
||||
function save(): void {
|
||||
|
||||
+12
-3
@@ -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
|
||||
are stand out from the Salvage Yard beside them. */
|
||||
.cardrow.aiming .handcard:not(.target){opacity:.4}
|
||||
.stock .ld.addable,.stock .mt.addable{cursor:pointer;border-radius:3px;padding:0 4px;
|
||||
outline:1px solid #5aa9e6;background:rgba(90,169,230,.16)}
|
||||
.stock .ld.addable:hover,.stock .mt.addable:hover{background:rgba(90,169,230,.34)}
|
||||
/* A CAR YOU MAY ADD IS AN ACTION, SO IT WEARS THE ACTION COLOUR — Jesse, playtest 2026-09-16: the
|
||||
highlight was "just a small bold and basically the same color as everything else", which is the
|
||||
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
|
||||
dimmed, and a slot the die has just filled flashes once. */
|
||||
.tt{display:flex;gap:3px;flex-wrap:wrap}
|
||||
|
||||
+21
-2
@@ -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}
|
||||
.wbehind{font-variant-numeric:tabular-nums;font-weight:700;color:#c9cee0;
|
||||
background:#22263a;border:1px solid #343a52;border-radius:10px;padding:1px 8px;white-space:nowrap}
|
||||
#watching-skip{font-size:11px;padding:2px 10px;border-color:#e0b060;color:#e0b060;flex:none}
|
||||
#watching-skip,#watching-pause{font-size:11px;padding:2px 10px;border-color:#e0b060;color:#e0b060;flex:none}
|
||||
#watching-who{color:#c9cee0;font-weight:700}
|
||||
#presence:empty{display:none}
|
||||
/* division strip */
|
||||
@@ -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;
|
||||
background:rgba(40,140,60,.22);border:1px solid #2f6b47;color:#bfe8cd;font-weight:600}
|
||||
.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,
|
||||
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. */
|
||||
@@ -205,6 +213,9 @@ button.act.crew.on{border-color:var(--now);background:rgba(185,140,240,.18);colo
|
||||
/* actions */
|
||||
#actions{max-height:none}
|
||||
.grp{margin-bottom:6px}
|
||||
/* What a role is for, under its heading — a sentence a player reads once and stops re-asking. Sized
|
||||
below the buttons so it explains without competing with the thing being chosen. */
|
||||
.grp .scope{margin:2px 0 4px;font-size:11.5px;line-height:1.35}
|
||||
/* THESE ARE THE THINGS YOU CAN DO. An action carried the same grey border as every other panel
|
||||
on the page, so the one region that is clickable did not look it. Amber border and a lit face,
|
||||
used nowhere else, so "this is a move" is answered before the label is read. */
|
||||
@@ -878,7 +889,7 @@ ul.blocked li{padding:2px 0}
|
||||
the front door: `index.html`'s two doors are `play.html?lobby` and `play.html?solitaire`, so
|
||||
arriving from the splash REPLACES the query string and any pace with it. Jesse played a whole
|
||||
game believing he was at 7x when he was at 1x. -->
|
||||
<span class="zoom" title="How long another player's or a bot's move is held on screen before the next one. Yours are never delayed. Off draws every move at once, as it did before v0.8.0.">
|
||||
<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>
|
||||
</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>
|
||||
@@ -919,6 +930,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
|
||||
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>
|
||||
<!-- 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-what"></span>
|
||||
</div>
|
||||
@@ -931,6 +946,10 @@ ul.blocked li{padding:2px 0}
|
||||
<h2><span id="districtwho">Your Office Area</span>
|
||||
<span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span>
|
||||
<span id="districttoggle" class="seg" role="group" aria-label="When to show your Office Area"><button id="dm-auto" class="ghost" type="button" title="Open during Local Operations and Cargo — the phases that change the district — and folded otherwise.">Auto-hide</button><button id="dm-open" class="ghost" type="button" title="Keep the Office Area open in every phase.">Always show</button><button id="dm-closed" class="ghost" type="button" title="Keep the Office Area folded in every phase. The summary line stays, so it reads as folded rather than missing.">Always hide</button></span>
|
||||
<!-- 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>
|
||||
<div id="districtsummary" class="dim"></div>
|
||||
<!-- THE RULE THAT SHAPES EVERY DISTRICT, said once where the district is.
|
||||
|
||||
+6
-1
@@ -56,7 +56,12 @@ type Step = {
|
||||
function rebuild(save: Save): { steps: Step[]; stoppedEarly: boolean } {
|
||||
const s = createGame({ id: `replay-${save.seed}`, seed: save.seed, config: SOLO_CONFIG, playerNames: ['player'] });
|
||||
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 lines = events
|
||||
.filter((e) => e.type !== 'actorChanged')
|
||||
|
||||
@@ -65,6 +65,25 @@ export type StepQueue = {
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -113,6 +132,8 @@ export function createStepQueue(
|
||||
let pending: DisplayStep[] = [];
|
||||
/** When the step now on screen is due to give way. Null when nothing is waiting. */
|
||||
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. */
|
||||
const dwell = (step: DisplayStep): number =>
|
||||
@@ -138,6 +159,9 @@ export function createStepQueue(
|
||||
shown = frame;
|
||||
pending = [];
|
||||
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.
|
||||
litPiles = [];
|
||||
flashedCards = [];
|
||||
@@ -150,6 +174,9 @@ export function createStepQueue(
|
||||
},
|
||||
|
||||
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) {
|
||||
// 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
|
||||
@@ -183,6 +210,9 @@ export function createStepQueue(
|
||||
},
|
||||
|
||||
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;
|
||||
for (const step of pending) show(step);
|
||||
pending = [];
|
||||
@@ -190,6 +220,24 @@ export function createStepQueue(
|
||||
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,
|
||||
behind: () => pending.filter((s) => dwell(s) > 0).length,
|
||||
showing: () => last,
|
||||
|
||||
+119
-1
@@ -7,7 +7,7 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { advance, pump } from '../src/engine/advance.ts';
|
||||
import { advance, badlyMadeUp, pump } from '../src/engine/advance.ts';
|
||||
import { applyIntent, areaOf, check, isBeingMadeUp } from '../src/engine/apply.ts';
|
||||
import { EXPEDITE_FAULT_PENALTY, HAND_LIMIT, MAX_CONSIST, STAGES_PER_DAY, TOTAL_ROLLING_STOCK } from '../src/engine/content.ts';
|
||||
import { legalActions } from '../src/engine/legal.ts';
|
||||
@@ -1582,3 +1582,121 @@ describe('§8.1 counts only trains AHEAD of the one departing (Gitea#26)', () =>
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* §7's make-up round is silent about what it could NOT do, and that silence was reported from a
|
||||
* table (2026-09-16): "train 5, the sparrow, has no coaches, which seems strange."
|
||||
*
|
||||
* The Sparrow's card calls for three coaches and nothing else. Every coach in that game had ended
|
||||
* up in the Classification Yard, which §2.2 returns only when the Division Yard runs bare — and the
|
||||
* Division Yard still held 46 freight cars, so it never would. `trainNeedingCars` therefore answered
|
||||
* "nothing to ask for" exactly as it answers "everything is full", the phase moved on, and the train
|
||||
* ran the length of the Division empty with no line anywhere saying why.
|
||||
*/
|
||||
describe('a train the Division Yard cannot supply says so', () => {
|
||||
/** Puts one train in the yard-filling state, with a yard holding only `types`. */
|
||||
const readyToFill = (yard: { type: string; loaded: boolean }[]) => {
|
||||
const s = game();
|
||||
// Stage 1's train is 1/2, the Crack Limited — coaches only, which is the shape that goes short.
|
||||
s.timetable = Array(STAGES_PER_DAY).fill(null);
|
||||
s.timetable[0] = 1;
|
||||
s.yards.divisionYard = yard as never;
|
||||
s.yards.classificationYard = [{ type: 'coach', loaded: false }] as never;
|
||||
s.clock.stage = 1;
|
||||
s.clock.phase = 'newTrain';
|
||||
return s;
|
||||
};
|
||||
|
||||
it('reports the shortfall, with the cars, the waiting pile and why it will not come back', () => {
|
||||
// A yard of pure freight: nothing the Crack Limited will take.
|
||||
const s = readyToFill([
|
||||
{ type: 'boxcar', loaded: true },
|
||||
{ type: 'hopper', loaded: false },
|
||||
]);
|
||||
const { events } = advance(s);
|
||||
const short = events.find((e) => e.type === 'makeUpShort');
|
||||
assert.ok(short, 'a train that could be given nothing reported nothing');
|
||||
assert.equal(short.type === 'makeUpShort' && short.placed, 0);
|
||||
assert.ok(
|
||||
short.type === 'makeUpShort' && short.missing.includes('coach'),
|
||||
'the report did not name the category the yard could not supply',
|
||||
);
|
||||
assert.ok(
|
||||
short.type === 'makeUpShort' && short.waiting === 1,
|
||||
'the report did not count the cars waiting in the Classification Yard',
|
||||
);
|
||||
assert.ok(
|
||||
short.type === 'makeUpShort' && short.divisionYardHolds === 2,
|
||||
'the report did not say how far the Division Yard is from bare, which is what §2.2 turns on',
|
||||
);
|
||||
});
|
||||
|
||||
it('says nothing when the round can still be asked for a car', () => {
|
||||
// The same train, with coaches available: the phase must STOP for them rather than report.
|
||||
const s = readyToFill([
|
||||
{ type: 'coach', loaded: true },
|
||||
{ type: 'coach', loaded: true },
|
||||
]);
|
||||
const { events, needsInput } = advance(s);
|
||||
assert.equal(needsInput, true, 'the phase should be waiting for a car to be placed');
|
||||
assert.equal(
|
||||
events.some((e) => e.type === 'makeUpShort'),
|
||||
false,
|
||||
'a train that can still be filled was reported short',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* §8.2 and the Small Yard's nose sort, together — Jesse's ruling of 2026-09-17 was "any train may
|
||||
* sort to the nose, and it is held if it cannot depart".
|
||||
*
|
||||
* The holding half already existed and needed no new code, which is worth pinning precisely because
|
||||
* it is easy to assume otherwise: `badlyMadeUp` is deliberately DIRECTION-FREE, so a train with its
|
||||
* whole consist ahead of the engine is a PUSHING train and fit to run. What §8.2 refuses is a
|
||||
* broken-backed train — the engine buried among its own cars — and a caboose anywhere but the end
|
||||
* away from the engine.
|
||||
*/
|
||||
describe('a train sorted to the nose is judged by §8.2, not by where the engine is', () => {
|
||||
const tray = (consist: { type: string; loaded: boolean }[], engineAt: number) =>
|
||||
({ consist, engineAt }) as unknown as CrewTray;
|
||||
|
||||
it('lets a pushing train run — the whole consist ahead of the engine is made up', () => {
|
||||
const pushing = tray([{ type: 'caboose', loaded: true }, { type: 'boxcar', loaded: true }], 2);
|
||||
assert.equal(
|
||||
badlyMadeUp(pushing),
|
||||
null,
|
||||
'a pushing train was refused; §8.2 is enforced direction-free on purpose',
|
||||
);
|
||||
});
|
||||
|
||||
it('holds a train whose engine is buried among its own cars', () => {
|
||||
const buried = tray(
|
||||
[{ type: 'boxcar', loaded: true }, { type: 'hopper', loaded: true }, { type: 'caboose', loaded: true }],
|
||||
1,
|
||||
);
|
||||
const why = badlyMadeUp(buried);
|
||||
assert.ok(why !== null, 'a broken-backed train was allowed to leave the Office');
|
||||
assert.match(why, /engine is buried/, `the hold did not say why: ${why}`);
|
||||
});
|
||||
|
||||
it('holds a train whose caboose is not at the end away from the engine', () => {
|
||||
// Exactly the arrangement asked for at the table: caboose second from the rear.
|
||||
const wanted = tray(
|
||||
[
|
||||
{ type: 'hopper', loaded: true },
|
||||
{ type: 'tank', loaded: false },
|
||||
{ type: 'caboose', loaded: true },
|
||||
{ type: 'boxcar', loaded: true },
|
||||
],
|
||||
0,
|
||||
);
|
||||
const why = badlyMadeUp(wanted);
|
||||
assert.ok(why !== null, 'a train with its caboose mid-consist was allowed to leave');
|
||||
assert.match(why, /caboose must be at the rear/, `the hold did not say why: ${why}`);
|
||||
});
|
||||
});
|
||||
|
||||
+30
-9
@@ -1361,6 +1361,10 @@ describe('the Limits bound the district, and the nine spots reach round a Facili
|
||||
const under = district(s);
|
||||
buildFacility(s, 'packingSheds', under);
|
||||
const cardId = modifierOf(s, 'iceHouse');
|
||||
// `district` leaves the host ON the sign's column, which since 2026-09-17 puts its three eastern
|
||||
// spots outside the Limits. The question here is whether DIAGONALS are offered at all, so the
|
||||
// sign goes out one more column and the host keeps all nine.
|
||||
areaOf(s, 0).limitsEast = at(areaOf(s, 0).runningRow, under.col + 1);
|
||||
|
||||
const offered = legalActions(s, 0)
|
||||
.filter((i) => i.type === 'card.play' && i.cardId === cardId && i.placement !== undefined)
|
||||
@@ -1375,13 +1379,18 @@ describe('the Limits bound the district, and the nine spots reach round a Facili
|
||||
assert.equal(check(s, 0, { type: 'card.play', cardId, placement: southEast }), null);
|
||||
});
|
||||
|
||||
it('lets a Modifier hang outside the Limits, but never in the Running Track row', () => {
|
||||
it('keeps a Modifier inside the Limits, and out of the Running Track row', () => {
|
||||
/**
|
||||
* Jesse's call, both halves. A Modifier is not track (§9), so a host standing at the limit keeps
|
||||
* all nine of its spots — refusing the outer three would make the card unplayable exactly where
|
||||
* the district ends. The Running Track ROW is the exception: inside the Limits that row is
|
||||
* always full, so this bites only beyond the sign, and that is the ground the main grows onto —
|
||||
* a Modifier parked there would block the player's own sign from moving outward (§2.1).
|
||||
* Jesse's call, both halves — and the FIRST half reversed on 2026-09-17 after a Day 3 playtest
|
||||
* put Transmission Lines at (-2,4) with the sign at column 3. It used to read the other way: a
|
||||
* Modifier is not track (§9), so a host at the limit kept all nine of its spots, because
|
||||
* refusing the outer three looked like it would make the card unplayable where the district
|
||||
* ends. What decided it was the board — a card standing outside your own sign, in territory
|
||||
* §8.1 and §10 reason about — and a count of what is actually lost: six of the nine spots
|
||||
* survive, and the sign moves outward as the Running Track grows (§2.1, Gap 4a).
|
||||
*
|
||||
* The Running Track ROW stays barred for its own reason: it is the ground the main grows onto,
|
||||
* and a Modifier parked there would block the player's own sign from moving outward.
|
||||
*/
|
||||
const s = game();
|
||||
const under = district(s);
|
||||
@@ -1390,9 +1399,16 @@ describe('the Limits bound the district, and the nine spots reach round a Facili
|
||||
const area = areaOf(s, 0);
|
||||
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'card.play', cardId, placement: at(under.row, under.col + 1) }),
|
||||
check(s, 0, { type: 'card.play', cardId, placement: at(under.row, area.limitsEast.col + 1) }),
|
||||
'OUTSIDE_LIMITS',
|
||||
'a Modifier was allowed to stand outside the district it belongs to',
|
||||
);
|
||||
// The spot inside the sign, beside the same host, is the one a player actually has — free,
|
||||
// adjacent, and on the sign's own column, which `withinLimits` includes.
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'card.play', cardId, placement: at(under.row - 1, under.col) }),
|
||||
null,
|
||||
'a Modifier beside a host at the limit was refused the spot outside it',
|
||||
'a Modifier was refused a free, connected spot inside the Limits',
|
||||
);
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'card.play', cardId, placement: at(area.runningRow, under.col + 1) }),
|
||||
@@ -1425,6 +1441,11 @@ describe("a Modifier grants only what its host's flow can use", () => {
|
||||
const area = areaOf(s, 0);
|
||||
const at = { row: -1, col: 4 };
|
||||
area.grid.set(coordKey(at), withFacility(kind, out, into) as never);
|
||||
// This test is about what a Modifier GRANTS, not about where it may stand, and it arranges a
|
||||
// host well east of the opening sign. Modifiers have been bounded by the Limits since
|
||||
// 2026-09-17, so the district has to reach the square the fixture uses or every case here would
|
||||
// fail as OUTSIDE_LIMITS and prove nothing about flow.
|
||||
area.limitsEast = { row: area.runningRow, col: at.col + 2 };
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
|
||||
const card = [...s.cards.entries()].find(
|
||||
@@ -1881,7 +1902,7 @@ describe('the engine is drawn pointing east or west, whatever track it is standi
|
||||
* So `facing` stays a PORT (movement needs one) and `railFacingOf` is what the board draws.
|
||||
*/
|
||||
const moved = (id: string, facing: 'n' | 's' | 'e' | 'w') =>
|
||||
({ type: 'trayMoved', player: 0, trayId: id, from: at(0, 0), to: at(0, 0), movesRemaining: 3, facing }) as const;
|
||||
({ type: 'trayMoved', player: 0, trayId: id, from: at(0, 0), to: at(0, 0), movesRemaining: 3, movesAllowed: 6, facing }) as const;
|
||||
|
||||
it('carries the east-west sense across north-south track', () => {
|
||||
const s = game();
|
||||
|
||||
@@ -12,6 +12,9 @@ import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
|
||||
import { legalActions } from '../src/engine/legal.ts';
|
||||
import { describeIntent } from '../src/sim/view.ts';
|
||||
import { badlyMadeUp } from '../src/engine/advance.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import type { CrewTray, GameConfig, GameState, GridCoord, RollingStock, TrackArc, TrackCard } from '../src/engine/state.ts';
|
||||
import { carsOn, coordKey, turnOf } from '../src/engine/state.ts';
|
||||
@@ -461,3 +464,161 @@ describe('a 45° leg is part of the west-to-east row, not outside it (Gitea#17)'
|
||||
assert.deepEqual(standingAt(s, at(1, 0)), [], 'the cars should have come off the card');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The Small Yard's re-order menu, reported from Day 3 of the 2026-09-17 playtest.
|
||||
*
|
||||
* Jesse had train 10 on his Small Yard — nose first, `[loaded boxcar, loaded hopper, empty tank,
|
||||
* caboose]` — wanted the boxcar on the tail, and could not tell which button did it: every option
|
||||
* read `re-order consist [1,2,3,0]`, the engine's own array indices offered to a person. The move he
|
||||
* wanted was the FIRST of the five. One of the other four re-ordered nothing at all and would still
|
||||
* have spent one of his six Moves.
|
||||
*/
|
||||
describe('the Small Yard says what each re-order would build', () => {
|
||||
const smallYard = (standing: RollingStock[] = []): TrackCard => ({
|
||||
...straight(standing),
|
||||
enhancements: ['smallYard'],
|
||||
});
|
||||
|
||||
/** Train 10's consist as it actually stood, on a card carrying a Small Yard. */
|
||||
const onTheYard = (): { s: GameState; trayId: string } => {
|
||||
const s = game();
|
||||
row(s, 3);
|
||||
addCard(s, at(1, 1), smallYard());
|
||||
const trayId = placeTray(s, at(1, 1), [car('boxcar', true), car('hopper', true), car('tank'), car('caboose', true)], 'e');
|
||||
switching(s);
|
||||
return { s, trayId };
|
||||
};
|
||||
|
||||
it('never offers a sort that changes nothing', () => {
|
||||
/**
|
||||
* THE PAIR IS WHAT COUNTS, since the engine position became part of a sort (2026-09-17). The
|
||||
* identity car order is now a perfectly good option when it moves the ENGINE — that is the whole
|
||||
* of the separate engine control — so what must never be offered is the pair that reproduces the
|
||||
* train already standing there, and no pair may appear twice.
|
||||
*/
|
||||
const { s, trayId } = onTheYard();
|
||||
const tray = s.trays.get(trayId)!;
|
||||
const offered = legalActions(s, 0)
|
||||
.filter((i) => i.type === 'switch.sortConsist')
|
||||
.map((i) => {
|
||||
const sort = i as { order: number[]; engineAt?: number };
|
||||
return `${sort.order.join(',')}|${sort.engineAt ?? 0}`;
|
||||
});
|
||||
assert.ok(offered.length > 0, 'no sort was offered at all, so this proved nothing');
|
||||
const unchanged = `${[...tray.consist.keys()].join(',')}|${tray.engineAt}`;
|
||||
assert.ok(
|
||||
!offered.includes(unchanged),
|
||||
'the menu offered the train as it already stands — a Move spent to change nothing',
|
||||
);
|
||||
assert.equal(new Set(offered).size, offered.length, 'the menu offered the same sort twice');
|
||||
});
|
||||
|
||||
it('offers the engine every position, including ahead of its own cars', () => {
|
||||
// Jesse's ruling, 2026-09-17, following `implications.md` against the v0.4.5 card text: a train
|
||||
// in a Small Yard "may sort itself into any order, INCLUDING cars ahead of the engine".
|
||||
const { s, trayId } = onTheYard();
|
||||
const n = s.trays.get(trayId)!.consist.length;
|
||||
const positions = legalActions(s, 0)
|
||||
.filter((i) => i.type === 'switch.sortConsist')
|
||||
.map((i) => (i as { engineAt?: number }).engineAt ?? 0);
|
||||
for (let k = 1; k <= n; k++) {
|
||||
assert.ok(positions.includes(k), `the engine was never offered position ${k} of ${n}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('always offers a made-up order to a train that is not in one', () => {
|
||||
/**
|
||||
* Jesse, 2026-09-17: "trains with a caboose have to offer the caboose at the back… sorting all
|
||||
* the cars back in, ready to leave the station."
|
||||
*
|
||||
* A train with its caboose mid-consist is one §8.2 will not let out of the Office, so at least
|
||||
* one offer has to put it right. It comes out of the ordinary curated set — "bring car k to the
|
||||
* tail" is enumerated for every car, and the caboose is one of them.
|
||||
*/
|
||||
const s = game();
|
||||
row(s, 3);
|
||||
addCard(s, at(1, 1), smallYard());
|
||||
const trayId = placeTray(s, at(1, 1), [car('boxcar', true), car('caboose', true), car('hopper', true)], 'e');
|
||||
const tray = s.trays.get(trayId)!;
|
||||
tray.trainNumber = 10;
|
||||
switching(s);
|
||||
|
||||
const fit = legalActions(s, 0)
|
||||
.filter((i) => i.type === 'switch.sortConsist')
|
||||
.filter((i) => {
|
||||
const sort = i as { order: number[]; engineAt?: number };
|
||||
const after = sort.order.map((n) => tray.consist[n]!);
|
||||
return badlyMadeUp({ ...tray, consist: after, engineAt: sort.engineAt ?? 0 }) === null;
|
||||
});
|
||||
assert.ok(
|
||||
fit.length > 0,
|
||||
'a train that cannot leave the Office was offered no sort that would make it up',
|
||||
);
|
||||
// And the one that does it says so on the button, rather than leaving it to be discovered.
|
||||
assert.ok(
|
||||
fit.some((i) => describeIntent(s, i).includes('MADE UP, ready to leave')),
|
||||
'the sort that makes the train up does not say so',
|
||||
);
|
||||
});
|
||||
|
||||
it('lays the train out west to east, with the engine where it will be', () => {
|
||||
/**
|
||||
* "You specify above that the order is front to back, but on the screen, if it's eastbound or
|
||||
* westbound, it may look different" — Jesse, 2026-09-17.
|
||||
*
|
||||
* `board-svg.ts` draws the crew strip west on the left, reversing a consist for an east-facing
|
||||
* train so its nose lands at the east end. The button has to read the same way or it describes a
|
||||
* different train from the one on the board.
|
||||
*/
|
||||
const s = game();
|
||||
row(s, 3);
|
||||
addCard(s, at(1, 1), smallYard());
|
||||
// Engine points WEST: the consist is stored nose first, so west to east reads engine first.
|
||||
const west = placeTray(s, at(1, 1), [car('boxcar', true), car('hopper', true)], 'w');
|
||||
switching(s);
|
||||
const westLabel = legalActions(s, 0)
|
||||
.filter((i) => i.type === 'switch.sortConsist')
|
||||
.map((i) => describeIntent(s, i))
|
||||
.find((l) => l.startsWith('re-order'));
|
||||
assert.ok(westLabel, 'no re-order was offered for the west-facing train');
|
||||
assert.match(westLabel, /west to east: ◀ ENGINE · /, `a west-facing engine was not drawn leading: ${westLabel}`);
|
||||
|
||||
// The same train pointing EAST puts the engine at the far end of the same sentence.
|
||||
s.trays.get(west)!.railFacing = 'e';
|
||||
s.trays.get(west)!.facing = 'e';
|
||||
const eastLabel = legalActions(s, 0)
|
||||
.filter((i) => i.type === 'switch.sortConsist')
|
||||
.map((i) => describeIntent(s, i))
|
||||
.find((l) => l.startsWith('re-order'));
|
||||
assert.ok(eastLabel, 'no re-order was offered for the east-facing train');
|
||||
assert.match(eastLabel, / · ENGINE ▶($| ·)/, `an east-facing engine was not drawn at the east end: ${eastLabel}`);
|
||||
});
|
||||
|
||||
it('labels each option with the train it would make, not with array indices', () => {
|
||||
const { s } = onTheYard();
|
||||
const labels = legalActions(s, 0)
|
||||
.filter((i) => i.type === 'switch.sortConsist')
|
||||
.map((i) => describeIntent(s, i));
|
||||
|
||||
assert.ok(
|
||||
labels.every((l) => !/\[\d(,\d)*\]/.test(l)),
|
||||
`a re-order option still reads as a permutation: ${labels.find((l) => /\[\d(,\d)*\]/.test(l))}`,
|
||||
);
|
||||
/**
|
||||
* The one Jesse wanted, written the way the board draws it: this crew faces EAST, so the strip
|
||||
* runs west to east and the engine sits at the east end with the boxcar now furthest west.
|
||||
*/
|
||||
assert.ok(
|
||||
labels.includes('re-order — west to east: loaded boxcar · caboose · empty tank · loaded hopper · ENGINE ▶'),
|
||||
`the move that puts the boxcar on the tail was not offered in words — got: ${labels.join(' | ')}`,
|
||||
);
|
||||
// And the engine's own positions read as what they do, not as an index.
|
||||
assert.ok(
|
||||
labels.some((l) => l.startsWith('put the whole consist ahead of the engine —')),
|
||||
`the shoving sort was not offered in words — got: ${labels.join(' | ')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,6 +47,12 @@ const KNOWN_UNREDUCED = [
|
||||
'clearanceRequested',
|
||||
'dispatchBonusUsed',
|
||||
'expediteFault',
|
||||
/**
|
||||
* The New Train Phase's report that it could give a train nothing (playtest, 2026-09-16, the
|
||||
* Sparrow running empty). Emitted by the phase driver after the make-up round has nothing left to
|
||||
* offer, so it describes rather than reduces, like every entry on this list.
|
||||
*/
|
||||
'makeUpShort',
|
||||
'phaseBegan',
|
||||
/**
|
||||
* §Q, Red Flags (Gitea#19). The flag comes down inside the phase driver as it stops a train, so
|
||||
@@ -61,6 +67,18 @@ const KNOWN_UNREDUCED = [
|
||||
// the pattern every entry on this list follows.
|
||||
'seatsRotated',
|
||||
'stageBegan',
|
||||
/**
|
||||
* §5's Fedora handover, emitted by `shiftChange` on the same mutate-then-describe path as its
|
||||
* neighbours here: the clock moves the Superintendent and then says so. Added 2026-09-16 because
|
||||
* riding on `actorChanged` meant the log dropped it as turn bookkeeping.
|
||||
*/
|
||||
'superintendentChanged',
|
||||
/**
|
||||
* The line that closes a switching turn. Emitted by `switch.end` beside the `phaseEnded` that
|
||||
* actually ends the turn, and reduces to nothing itself: it reports what the Moves were spent on
|
||||
* and where the crew was left, both of which the state already holds.
|
||||
*/
|
||||
'switchingEnded',
|
||||
'trainArrived',
|
||||
'trainCompleted',
|
||||
'trainDiverted',
|
||||
|
||||
+44
-3
@@ -44,8 +44,12 @@ const SAMPLES: GameEvent[] = [
|
||||
{ type: 'stageBegan', day: 1, stage: 7 },
|
||||
{ type: 'phaseBegan', phase: 'mainline' },
|
||||
{ type: 'actorChanged', player: 0 },
|
||||
// Sampled rather than left to swell the unsampled count: this sentence is one a player reads at
|
||||
// the table every third Stage, so its text is worth exercising.
|
||||
{ type: 'superintendentChanged', player: 1, stage: 6 },
|
||||
{ type: 'localOpsOptionChosen', player: 0, option: 'switch' },
|
||||
{ type: 'trayMoved', player: 0, trayId: 't0', from: { row: 0, col: 0 }, to: { row: 0, col: 1 }, movesRemaining: 5 },
|
||||
{ type: 'trayMoved', player: 0, trayId: 't0', from: { row: 0, col: 0 }, to: { row: 0, col: 1 }, movesRemaining: 5, movesAllowed: 6 },
|
||||
{ type: 'switchingEnded', player: 0, movesUsed: 3, movesAllowed: 6, lastMove: { trayId: 't0', to: { row: 0, col: 1 } } },
|
||||
{ type: 'carsCoupled', player: 0, trayId: 't0', at: { row: 0, col: 1 }, stock: [{ type: 'hopper', loaded: false }], from: [{ row: 0, col: 1 }], toNose: true },
|
||||
{ type: 'carsDropped', player: 0, trayId: 't0', at: { row: 1, col: 0 }, stock: [{ type: 'hopper', loaded: false }] },
|
||||
{ type: 'cardDrawn', player: 0, source: 'homeOffice', cardId: 'c1' },
|
||||
@@ -58,8 +62,18 @@ const SAMPLES: GameEvent[] = [
|
||||
{ type: 'inboundCleared', player: 0, at: { row: 1, col: 0 }, stock: { type: 'hopper', loaded: true } },
|
||||
{ type: 'facilityUnjammed', player: 0, at: { row: 1, col: 0 }, from: 'menAtWork', stock: { type: 'hopper', loaded: true } },
|
||||
{ type: 'trainScheduled', player: 0, trainNumber: 4, roll: 7, slot: 6, rngState: 1 },
|
||||
{ type: 'carPlacedOnTrain', player: 0, trayId: 't0', stock: { type: 'coach', loaded: false } },
|
||||
{ type: 'carPassed', player: 0, trayId: 't0' },
|
||||
{ type: 'carPlacedOnTrain', player: 0, trayId: 't0', stock: { type: 'coach', loaded: false }, trainNumber: 10, isExtra: false },
|
||||
{ type: 'carPassed', player: 0, trayId: 't0', trainNumber: 10, isExtra: false },
|
||||
{
|
||||
type: 'makeUpShort',
|
||||
trainNumber: 5,
|
||||
isExtra: false,
|
||||
placed: 0,
|
||||
wanted: 3,
|
||||
missing: ['coach'],
|
||||
waiting: 14,
|
||||
divisionYardHolds: 46,
|
||||
},
|
||||
{ type: 'clearanceRequested', trainId: 't1', occupiedBy: 't0' },
|
||||
{ type: 'clearanceGiven', trainId: 't1', allow: false },
|
||||
{ type: 'passengersBoarded', player: 0, at: { row: 0, col: 0 }, trayId: 't0', coachIndex: 0 },
|
||||
@@ -146,6 +160,33 @@ describe('narration', () => {
|
||||
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', () => {
|
||||
const n = narrate({ type: 'loadCompleted', player: 0, at: { row: 1, col: 2 }, carType: 'hopper' });
|
||||
assert.deepEqual(n.where, { row: 1, col: 2 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -109,6 +109,61 @@ describe('the step queue', () => {
|
||||
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', () => {
|
||||
// The counter's whole purpose: a backlog of mostly-bookkeeping must not read as a huge number
|
||||
// that collapses the instant it starts.
|
||||
|
||||
+193
-3
@@ -12,9 +12,10 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { applyIntent } from '../src/engine/apply.ts';
|
||||
import { applyIntent, areaOf } from '../src/engine/apply.ts';
|
||||
import { legalActions } from '../src/engine/legal.ts';
|
||||
import type { GameConfig, PlayerIndex } from '../src/engine/state.ts';
|
||||
import { turnOf } from '../src/engine/state.ts';
|
||||
import { fromMultiplayerSave, newGame, newMultiplayerGame, submit } from '../src/web/game.ts';
|
||||
import { currentActor } from '../src/web/game.ts';
|
||||
import { applyPublicDelta } from '../src/sim/public-delta.ts';
|
||||
@@ -55,7 +56,18 @@ const config: GameConfig = {
|
||||
const SWITCHING_EVENTS = ['trayMoved', 'carsCoupled', 'carsDropped', 'consistSorted'] as const;
|
||||
|
||||
/** How each of those four reads in the log, so the assertions can find them by text. */
|
||||
const SWITCHING_LINE = /^Player .+ (moved (Train |the local crew)|coupled \d+ car|set out |used the SMALL YARD)/;
|
||||
const SWITCHING_LINE = /^Player .+ (moved (Train |the local crew)|coupled at |set out |used the SMALL YARD)/;
|
||||
|
||||
/**
|
||||
* The two tones a switching line may carry, and why attribution matters in BOTH.
|
||||
*
|
||||
* Since 2026-09-17 a plain move along your own track is written `trace`: the line still exists, so
|
||||
* its display step has narration to caption the board with and a dwell to be watched for, but the
|
||||
* history panel does not draw it (`web/game.ts` § inHistory). What must never happen either way is
|
||||
* the line failing to say whose crew it was — the caption is read by the whole table while the move
|
||||
* goes up, which is if anything the more public of the two places.
|
||||
*/
|
||||
const SWITCHING_TONES = ['act', 'trace'];
|
||||
|
||||
describe('switching is attributed — TODO #13', () => {
|
||||
it('every switching event carries the player who acted', () => {
|
||||
@@ -124,7 +136,10 @@ describe('switching is attributed — TODO #13', () => {
|
||||
`an unattributed switching line survived: ${line.text}`,
|
||||
);
|
||||
if (SWITCHING_LINE.test(line.text)) {
|
||||
assert.equal(line.tone, 'act', `a switching line must read as somebody's move: ${line.text}`);
|
||||
assert.ok(
|
||||
SWITCHING_TONES.includes(line.tone),
|
||||
`a switching line must read as somebody's move: ${line.text} (tone ${line.tone})`,
|
||||
);
|
||||
lines++;
|
||||
}
|
||||
}
|
||||
@@ -310,6 +325,33 @@ describe('steps reach a seated player — TODO #13', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Fedora passing is visible (playtest 2026-09-16)', () => {
|
||||
it('names the new Superintendent in the history at the Stage it happens', async () => {
|
||||
const { newMultiplayerGame, currentActor, submit } = await import('../src/web/game.ts');
|
||||
const { legalActions } = await import('../src/engine/legal.ts');
|
||||
|
||||
/**
|
||||
* It used to ride on `actorChanged`, which `record()` drops as turn bookkeeping — so the one
|
||||
* moment that event meant something never reached a player. Driven far enough to cross a shift
|
||||
* boundary (Stages 3, 6, 9, 12) rather than asserted on a hand-built event, because the point is
|
||||
* that a real game produces the line.
|
||||
*/
|
||||
const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']);
|
||||
for (let i = 0; i < 900; 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;
|
||||
if (game.state.clock.stage > 3 || game.state.clock.day > 1) break;
|
||||
}
|
||||
|
||||
const handover = game.log.filter((l) => /SUPERINTENDENT — the Fedora passes to/.test(l.text));
|
||||
assert.ok(handover.length > 0, 'the game crossed a shift change and the log never said so');
|
||||
assert.match(handover[0]!.text, /Alice|Bob|Carol/, 'the handover did not name the new Superintendent');
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
@@ -377,3 +419,151 @@ describe('the log says who acted, once, and in what capacity (Gitea#30, #31)', (
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* HOW MUCH SWITCHING REACHES THE HISTORY PANEL — Jesse's ruling, 2026-09-17, asked as a question
|
||||
* from the table: *"Does switching show up in history at all? Should it? … I don't think I want all
|
||||
* six moves showing up… maybe dropping off or picking up cars in industries should be recorded."*
|
||||
*
|
||||
* It all showed up. A six-Move turn wrote a line per move, every one of them a pair of coordinates,
|
||||
* and two players shunting pushed everything else off the panel. What stays is the line saying
|
||||
* somebody switched, work at an INDUSTRY, and the Small Yard sort.
|
||||
*
|
||||
* THE LINE IS STILL WRITTEN, MARKED `trace`. `dwellForStep` gives a step no dwell when it produced
|
||||
* no narration, so dropping these outright stopped the board replaying switching at all — the first
|
||||
* attempt at this did exactly that and the step-queue suite caught it. The tone is the seam: the
|
||||
* caption still has its text, the panel filters the tone out.
|
||||
*/
|
||||
describe('the history panel keeps the switching that matters', () => {
|
||||
const play = (seed: number, steps: number) => {
|
||||
const game = newMultiplayerGame(seed, config, ['Alice', 'Bob', 'Carol']);
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const actor = currentActor(game);
|
||||
if (actor === null) break;
|
||||
const options = legalActions(game.state, actor);
|
||||
if (options.length === 0) break;
|
||||
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
|
||||
if (!submit(game, move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
|
||||
}
|
||||
return game;
|
||||
};
|
||||
|
||||
it('keeps the first move of a turn and traces the ones after it', () => {
|
||||
const game = play(1917398, 800);
|
||||
const moves = game.log.filter((l) => / moved (Train|the local crew)/.test(l.text));
|
||||
assert.ok(moves.length > 0, 'no crew move reached the log, so this proved nothing');
|
||||
|
||||
// The opener of a turn is the move that leaves `movesAllowed - 1` behind it, which the line
|
||||
// prints — "5 of 6 Moves left". Those are drawn; everything after them is caption-only.
|
||||
const opening = moves.filter((l) => / 5 of 6 Moves left/.test(l.text));
|
||||
const later = moves.filter((l) => !/ 5 of 6 Moves left/.test(l.text));
|
||||
assert.ok(opening.length > 0, 'no turn opened with a move, so this proved nothing');
|
||||
assert.ok(later.length > 0, 'no turn made a second move, so this proved nothing');
|
||||
assert.ok(
|
||||
opening.every((l) => l.tone !== 'trace'),
|
||||
`the first move of a switching turn was hidden: ${opening.find((l) => l.tone === 'trace')?.text}`,
|
||||
);
|
||||
assert.ok(
|
||||
later.every((l) => l.tone === 'trace'),
|
||||
`a move from the middle of a turn is still drawn: ${later.find((l) => l.tone !== 'trace')?.text}`,
|
||||
);
|
||||
// Every one keeps its text, because that is what captions the board as the move goes up.
|
||||
assert.ok(moves.every((l) => /^Player /.test(l.text)), 'a trace line lost its attribution');
|
||||
});
|
||||
|
||||
it('closes a switching turn with what it cost and where the crew was left', () => {
|
||||
/**
|
||||
* BUILT, NOT PLAYED — the driver above never submits `switch.end`: it finds a move or another
|
||||
* option every time, so 800 turns produced no closed switching turn at all and the assertion
|
||||
* would have been vacuous.
|
||||
*/
|
||||
const { game, trayId } = crewOnAnIndustry();
|
||||
assert.ok(submit(game, { type: 'switch.move', trayId, to: { row: 1, col: 2 }, reverse: false }));
|
||||
assert.ok(submit(game, { type: 'switch.end' }), 'the turn would not end');
|
||||
|
||||
const closing = game.log.filter((l) => / finished switching/.test(l.text));
|
||||
assert.equal(closing.length, 1, `expected one closing line, got ${closing.length}`);
|
||||
assert.notEqual(closing[0]!.tone, 'trace', 'the closing summary was hidden from the history');
|
||||
assert.match(
|
||||
closing[0]!.text,
|
||||
/1 of 6 Moves used, leaving .* at /,
|
||||
`the closing line did not say what it cost and where the crew was left: ${closing[0]!.text}`,
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* A crew standing on a Freight House with a loaded boxcar, and plain track to its east.
|
||||
*
|
||||
* BUILT RATHER THAN PLAYED. The bot prefers moves over couplings, so 2400 driven turns across
|
||||
* three seeds produced not one set-out at an industry, and none of them ever ended a switching
|
||||
* turn — a driver that cannot reach the case cannot test it.
|
||||
*/
|
||||
const crewOnAnIndustry = (): { game: ReturnType<typeof newGame>; trayId: string } => {
|
||||
const game = newGame(77);
|
||||
const area = areaOf(game.state, 0);
|
||||
const spot = { row: 1, col: 1 };
|
||||
const track = (over: object = {}) => ({
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
...over,
|
||||
});
|
||||
area.grid.set('1,1', track({
|
||||
geometry: { kind: 'facility', facility: 'freightHouse', axis: 'ew' },
|
||||
facility: {
|
||||
kind: 'freight', subtype: 'freightHouse',
|
||||
allows: { outbound: true, inbound: true },
|
||||
outboundBox: [], inboundBox: [],
|
||||
capacity: { outbound: 1, inbound: 1 },
|
||||
menAtWork: [null, null, null],
|
||||
industryTrack: { cars: [] },
|
||||
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
}) as never);
|
||||
area.grid.set('1,2', track() as never);
|
||||
|
||||
const trayId = game.state.freeTrays.pop()!;
|
||||
game.state.trays.set(trayId, {
|
||||
id: trayId, trainNumber: null, trainIsExtra: false, engineAt: 0,
|
||||
consist: [{ type: 'boxcar', loaded: true }],
|
||||
direction: 'east', facing: 'e', railFacing: 'e',
|
||||
position: { at: 'grid', seat: 0, coord: spot }, movesUsed: 0,
|
||||
} as never);
|
||||
game.state.clock.phase = 'localOps';
|
||||
game.state.clock.currentActor = 0;
|
||||
turnOf(game.state, 0).option = 'switch';
|
||||
return { game, trayId };
|
||||
};
|
||||
|
||||
it('keeps work at an industry, and drops the same move on plain track', () => {
|
||||
const { game, trayId } = crewOnAnIndustry();
|
||||
const plain = { row: 1, col: 2 };
|
||||
assert.ok(submit(game, { type: 'switch.dropCars', trayId, count: 1 }), 'the set-out was refused');
|
||||
const dropped = game.log.filter((l) => /set out/i.test(l.text));
|
||||
assert.equal(dropped.length, 1, `expected one set-out line, got ${dropped.length}`);
|
||||
assert.notEqual(
|
||||
dropped[0]!.tone,
|
||||
'trace',
|
||||
'work at an industry was hidden from the history — it is the point of switching',
|
||||
);
|
||||
assert.match(
|
||||
dropped[0]!.text,
|
||||
/at the Freight House/,
|
||||
`the line named a coordinate instead of the industry: ${dropped[0]!.text}`,
|
||||
);
|
||||
|
||||
// The same crew moving onto ordinary track is the noise this ruling was about.
|
||||
assert.ok(submit(game, { type: 'switch.move', trayId, to: plain, reverse: false }), 'the move was refused');
|
||||
const moved = game.log.filter((l) => / moved the local crew/.test(l.text));
|
||||
assert.equal(moved.length, 1, `expected one move line, got ${moved.length}`);
|
||||
// The FIRST move of a turn is kept, and this crew's first move is this one — so what is being
|
||||
// checked here is that it names the square by what stands on it rather than by its coordinates.
|
||||
assert.match(moved[0]!.text, /→ the Freight House|→ \(1,2\)/, `unexpected move line: ${moved[0]!.text}`);
|
||||
});
|
||||
});
|
||||
|
||||
+125
-4
@@ -1522,6 +1522,15 @@ describe('the static build', () => {
|
||||
attrs,
|
||||
setAttribute: (k: string, v: string) => void (attrs[k] = v),
|
||||
getAttribute: (k: string) => attrs[k] ?? null,
|
||||
/**
|
||||
* A STUB THAT CANNOT MODEL A METHOD SHIPS THE CONTROL GREEN AND UNEXERCISED — the lesson
|
||||
* `setAttribute` taught this factory above, learned again on 2026-09-16.
|
||||
*
|
||||
* The Office Area's per-seat buttons are built with `createElement` and appended, because a
|
||||
* display name is another player's text and must never be interpolated into markup. Without
|
||||
* this the district panel throws on every render.
|
||||
*/
|
||||
appendChild: () => {},
|
||||
textContent: '', style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0,
|
||||
title: '', returnValue: '', open: false,
|
||||
addEventListener: (type: string, fn: (e?: unknown) => void) =>
|
||||
@@ -1779,6 +1788,15 @@ describe('the static build', () => {
|
||||
getAttribute: (k: string) => attrs[k] ?? null,
|
||||
textContent: '', style: {}, dataset: {}, onclick: null, disabled: false,
|
||||
title: '', returnValue: '', open: false,
|
||||
/**
|
||||
* A STUB THAT CANNOT MODEL A METHOD SHIPS THE CONTROL GREEN AND UNEXERCISED — the lesson
|
||||
* `setAttribute` taught these factories in 2026-08-30, learned again on 2026-09-16.
|
||||
*
|
||||
* The Office Area's per-seat buttons are built with `createElement` and appended, because a
|
||||
* display name is another player's text and must never be interpolated into markup. Without
|
||||
* this the district panel throws on every render.
|
||||
*/
|
||||
appendChild: () => {},
|
||||
addEventListener: (type: string, fn: (e?: unknown) => void) =>
|
||||
void listeners.set(type, [...(listeners.get(type) ?? []), fn]),
|
||||
showModal() {
|
||||
@@ -1894,6 +1912,15 @@ describe('the static build', () => {
|
||||
getAttribute: (k: string) => attrs[k] ?? null,
|
||||
textContent: '', style: {}, dataset: {}, onclick: null, disabled: false,
|
||||
title: '', returnValue: '', open: false,
|
||||
/**
|
||||
* A STUB THAT CANNOT MODEL A METHOD SHIPS THE CONTROL GREEN AND UNEXERCISED — the lesson
|
||||
* `setAttribute` taught these factories in 2026-08-30, learned again on 2026-09-16.
|
||||
*
|
||||
* The Office Area's per-seat buttons are built with `createElement` and appended, because a
|
||||
* display name is another player's text and must never be interpolated into markup. Without
|
||||
* this the district panel throws on every render.
|
||||
*/
|
||||
appendChild: () => {},
|
||||
addEventListener: () => {},
|
||||
showModal() {},
|
||||
close() {},
|
||||
@@ -2641,8 +2668,16 @@ describe('the static build', () => {
|
||||
*/
|
||||
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');
|
||||
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 [
|
||||
['a clearance ruling', 'Train 4'],
|
||||
@@ -2672,10 +2707,15 @@ describe('the static build', () => {
|
||||
it('names the Superintendent at a table, and stays quiet about it in solitaire', () => {
|
||||
/**
|
||||
* REPORTED BY JESSE 2026-08-23, playing two-player on StartOS: seat 1 played a train card and
|
||||
* seat 2 was asked to build the train. The engine was right — §7 makes a consist up "starting
|
||||
* with the Superintendent and working left" — but nothing on the board said who the
|
||||
* seat 2 was asked to build the train. The engine was right — §7 makes a consist up starting
|
||||
* with the Superintendent and working EASTWARD — but nothing on the board said who the
|
||||
* Superintendent WAS, so the question could not be answered from the screen. The Frame has
|
||||
* carried `superintendent` since v0.4.0 and only the standalone replay ever drew it.
|
||||
*
|
||||
* The rule text said "working left" until 2026-09-16. It is the same rule — `playerLeftOf` is
|
||||
* increasing seat index — but "left" describes a table nobody is looking at, while the map on
|
||||
* screen runs west to east, so at a real three-player game it read as plainly wrong: the second
|
||||
* car went to the player sitting to the EAST. The word changed; the order did not.
|
||||
*/
|
||||
const frame = { day: 1, stage: 4, clock: '2:00', phase: 'New Train', phaseKey: 'newTrain', actor: 1 };
|
||||
const table = turnChartHtml(frame, 'Bob', 'Bob');
|
||||
@@ -2689,7 +2729,7 @@ describe('the static build', () => {
|
||||
const src = readFileSync(join(root, 'src/sim/turnchart.ts'), 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/starting with the Superintendent and working left/,
|
||||
/starting with the Superintendent and working eastward/,
|
||||
'the New Train pill does not say whose turn the make-up round starts on',
|
||||
);
|
||||
});
|
||||
@@ -2767,6 +2807,21 @@ describe('the static build', () => {
|
||||
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.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', () => {
|
||||
@@ -4147,6 +4202,16 @@ describe('the lobby screen', () => {
|
||||
addEventListener: () => {}, showModal: () => {}, close: () => {}, focus: () => {},
|
||||
querySelectorAll: (sel: string) => matching(sel),
|
||||
querySelector: (sel: string) => matching(sel)[0] ?? null,
|
||||
/**
|
||||
* A STUB THAT CANNOT MODEL A METHOD SHIPS THE CONTROL GREEN AND UNEXERCISED — the same
|
||||
* lesson `setAttribute` taught this factory in 2026-08-30, learned again on 2026-09-16.
|
||||
*
|
||||
* The Office Area's per-seat buttons are built with `createElement` and appended, because a
|
||||
* display name is another player's text and must never be interpolated into markup. Without
|
||||
* this the district panel threw on every render, which took out 21 tests across three suites
|
||||
* — and the thing it was hiding was a control nothing had ever exercised.
|
||||
*/
|
||||
appendChild: () => {},
|
||||
};
|
||||
Object.defineProperty(node, 'innerHTML', { get: () => html, set: (v: string) => void (html = v) });
|
||||
return node;
|
||||
@@ -4205,6 +4270,30 @@ describe('the lobby screen', () => {
|
||||
const chosen = (groups: Record<string, { value: string; checked: boolean }[]>, name: string): string | undefined =>
|
||||
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 () => {
|
||||
// Somebody who was handed a code used to have to scroll past the entire create form to find the
|
||||
// box to type it into.
|
||||
@@ -4520,6 +4609,9 @@ describe('the solitaire setup screen', () => {
|
||||
style: {}, dataset: {}, onclick: null, oninput: null, onchange: null, scrollTop: 0, scrollHeight: 0,
|
||||
checked: false, disabled: false, hidden: false, className: '',
|
||||
classList: { add: () => {}, remove: () => {}, contains: () => false, toggle: () => {} },
|
||||
// The Office Area's per-seat buttons are created and appended rather than interpolated, so a
|
||||
// node that cannot be appended to throws on every render — see the note in the other factory.
|
||||
appendChild: () => {},
|
||||
addEventListener: (type: string, fn: () => void) =>
|
||||
void listeners.set(type, [...(listeners.get(type) ?? []), fn]),
|
||||
showModal: () => void ((node as { open: boolean }).open = true),
|
||||
@@ -5388,3 +5480,32 @@ describe('the Superintendent ruling names the train it is ruling on', () => {
|
||||
assert.match(title, /Train 4/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* §2.2 and §9.2 together strand every coach in the Classification Yard, and the board does not say
|
||||
* so — Jesse's ruling of 2026-09-17 is that the rule stands and the game says it loudly.
|
||||
*
|
||||
* Tested on the FRAME rather than the DOM, because the counts are what the warning is derived from
|
||||
* and they are what could go wrong: the panel asks for a per-type count the Frame already carries.
|
||||
*/
|
||||
describe('a Division Yard with no coaches is a reportable condition', () => {
|
||||
it('carries per-type counts for both yards, so the panel can tell coaches from cars', () => {
|
||||
const game = newGame(4242);
|
||||
game.state.yards.divisionYard = [{ type: 'boxcar', loaded: true }, { type: 'hopper', loaded: false }] as never;
|
||||
game.state.yards.classificationYard = [
|
||||
{ type: 'coach', loaded: false },
|
||||
{ type: 'coach', loaded: true },
|
||||
] as never;
|
||||
const f = view(game);
|
||||
|
||||
const coachesInDivision = f.yards.division.find((c) => c.type === 'coach');
|
||||
assert.equal(coachesInDivision, undefined, 'the fixture put no coach in the Division Yard');
|
||||
assert.notEqual(f.yards.divisionTotal, 0, 'a yard holding freight is not bare, which is the whole point');
|
||||
|
||||
const waiting = f.yards.classification.find((c) => c.type === 'coach');
|
||||
assert.ok(waiting, 'the coaches in Classification were not reported by type');
|
||||
assert.equal(waiting.loaded + waiting.empty, 2, 'the waiting coaches were miscounted');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user