Compare commits

..
11 Commits
Author SHA1 Message Date
Jesse.MarkowitzandClaude Opus 5 a6657241de v0.8.0.14 — the coaches that never come back, and a district that ends at its own sign
Six reports from the Day 2-3 playtest of v0.8.0.13.

GAMES IN PROGRESS DO NOT SURVIVE THIS ONE. Modifiers are now bounded by the
Limits, which makes a once-legal move illegal, so a save holding one is refused
at that move: whistle-6945.day3.stage10 stops at intent 528 of 539. Jesse's call,
knowing it strands the game on the box. The file is untouched and v0.8.0.13
still finishes it.

The Sparrow running empty and Tom unable to unload his passengers are the same
shortage from opposite ends, and both are the rules working as printed. §9.2
boarding discards the emptied coach into the CLASSIFICATION yard, detraining
draws a fresh empty out of the DIVISION yard, and §2.2 sends Classification back
only when the Division Yard runs bare — so coaches move one way. Measured over
the save: sixteen in the Division Yard at setup, zero from Day 2 Stage 8 to the
end, fifteen piled in Classification, the Division Yard steady at 46-47 freight
cars with no prospect of going bare. Jesse's ruling is Gitea#2's: the shortage
stays and the game says so. A train made up short now reports what its card
wanted and why none is coming (`makeUpShort` — `trainNeedingCars` answered null
for "done" and for "cannot be done" alike, so the phase moved on in silence); the
yard panel warns while the condition lasts; the Depot's blocked panel was right
all along.

The modifier outside the Limits was working as designed and the design was
Jesse's own call, now reversed. What decided it is what the board shows — a card
beyond your own sign, in territory §8.1 and §10 reason about. The case that
motivated the exemption was checked on the reported move rather than argued away:
the Power Plant sat at (-1,3) against a sign at column 3 and two spots inside
were free, legal and adjacent.

Switching filled the history with coordinates — a line per move, plus one per
mandatory coupling. It is still LOGGED in full; what the panel draws is the line
saying somebody switched, the first move, work at an INDUSTRY (named, not a
coordinate), the Small Yard sort, and a closing summary. The suppressed lines are
still WRITTEN, marked `trace`: dropping them outright was the first attempt and
the step-queue suite caught it, because dwellForStep pays nothing for a step that
said nothing, so the board stopped replaying switching at all. The last move
rides in the closing line 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 streamed to
every client and cannot be revised. Two things fell out of reading those lines:
every move ended with a tutorial sentence the opener already gives, and the move
count said "of 6" with the six hardcoded, which is wrong on a night Stage.

Make-up lines name their train — they all read "the train being made up", so
looking back for train 10 found nothing under that name — and "a empty tank" is
now "an empty tank". The Small Yard's options read as the train they would build
instead of `[1,2,3,0]`; the one Jesse wanted was the first of five and unreadable.
Two of those five were junk: bringing the last car to the end is the identity and
would have spent a Move, and a two-car reversal duplicated its only real option.
Both are filtered by the resulting order, not by the case that made them.

A Small Yard may now put cars AHEAD of the engine, which was Jesse's own open
question. Two sources disagreed and the design notes won: the v0.4.5 card text
says the sort puts the engine at the nose, implications.md says "any order,
including cars ahead of the engine". `engineAt` is optional on the intent, so
older saves replay to the same train. The menu did not multiply — the engine is a
separate short list against the consist as it stands, eight options for a
four-car train rather than twenty. §8.2 needed no new code: badlyMadeUp is
deliberately direction-free, so a PUSHING train is fit to run and only a
broken-backed one is held. The button warns by asking that predicate rather than
copying it, and immediately earned itself — every one of train 10's eight options
is refused, the one asked for at the table included, because that train carries a
caboose and each sort moves it off the rear. That is the right answer rather than
a gap: the train is already made up, so every offer would break it, and the labels
say which is which. A made-up order is always on the menu for a train that needs
one, because "bring car k to the tail" is enumerated for every car and the caboose
is one of them.

Labels read WEST TO EAST, with the engine drawn as the board's own ◀ / ▶ arrow.
"Front to back" is not a direction a table can read — which end is the front
depends on which way the train points — and board-svg has reversed east-facing
consists since v0.8.0, so the button now describes the same train as the picture.

The Freight Agent, Porter and Laborer groups now say what the role is for, where
the role is chosen. Tom reached for the Freight Agent to detrain passengers,
which is a Porter's action in the Cargo phase; both halves were working and
neither was visible.

TODO closes #107 (the nose sort) and gains #108 (the coach ratchet, with the
measurement, to revisit on a second game's data).

999 fast tests and 35 sim tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DmdqqCNoiqE7GBo6wthBnR
2026-09-17 20:49:31 -04:00
Jesse.MarkowitzandClaude Opus 5 ad277fb994 v0.8.0.13 — the board on screen is the board you can act on
Nine reports from the Day 1-2 playtest of v0.8.0.12. One moved 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 real bug: the Division Yard chips stayed lit and clickable while the board
was catching up. `renderActions` puts the action list away while the queue is
behind — 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. A chip was clicked
during a bot's make-up, a coach left the yard, and the train ended up with three
cars: a real intent submitted against a board several moves stale. The chips now
follow the queue like every other control, and the yard COUNTS are drawn from the
shown board rather than the live game — they were the one panel still reporting a
future the player had not been shown.

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. Your own seat is in the row now, and the row is ordered by SEAT,
west to east as the Division map draws it, rather than by join order — sorted
from the Frame's own `seat` on every render, so it rotates with Employee Rotation
instead of having to be told.

§5's handover of the Fedora rode on `actorChanged`, which is turn bookkeeping and
which `record()` drops as noise, so the one moment it carried that a player needed
went past in silence. It is its own event now, narrated and announced. The phase
keeps its name: the Supervisor Shift refreshes every Laborer and Porter EVERY
Stage and the Fedora moves only every third.

A collision now names whose Office it was and who paid the 5 Revenue, which rode
in a separate `revenueChanged`; a Mainline collision is phrased differently
because §10 makes it the Superintendent's.

Passengers, reported as a bug and ruled not one after replaying the save: the
Depot's capacity and modifiers were fine, and §6.3 stocking wants a LOADED coach
out of the Division Yard, which held none while six sat in Classification. The
shortage stays — running out is part of the game, the same ruling Gitea#2 got —
but the blocked panel says so now instead of the action being silently absent.

Smaller: "working left" is "working eastward" in the make-up panel and the New
Train tip, because the map runs west to east and the table does not; the history
panel keeps 90 lines instead of 60 in the same 230px box.

`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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DmdqqCNoiqE7GBo6wthBnR
2026-09-17 04:43:59 -04:00
Jesse.MarkowitzandClaude Opus 5 7c9ef8797d v0.8.0.12 — put a player back in their seat after losing their browser storage
Gitea#33. A session token is the only identity the game has, and it lives in
exactly one place the player controls: their browser's localStorage, scoped to
the origin they joined at. Lose it — a cleared profile, a private window, a
different browser — and the seat is unreachable while the game runs on and the
session sits intact on disk. Reported from the table: of two humans in one game
the host reloaded straight back in, the joiner met an empty lobby.

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

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

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

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

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

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

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

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

The engine:

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

The screen:

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nnuCv8UodHucFfx3LWEoX
2026-09-15 22:44:20 -04:00
Jesse.MarkowitzandClaude Opus 5 76c6e103b3 v0.8.0.9 — the bot plans its switching turn, stops wasting its draws, and the engine walks each route once
The developer bot, re-measured decision by decision against the bot before it,
goes from about -0.3 revenue a game to about 4.8:

- plans the whole switching turn before its first Move (sim/switch-planner.ts),
  +2.89 over 1600 paired seeds; closes TODO #53
- takes a face-up card only if it could play it, +1.52 over 1600 seeds
- stops running Second Sections by accident in the New Train phase, +0.32
- lays track by what the district can do afterwards, +0.12 over 6400 seeds,
  run-arounds in 22 of 60 districts against 9

The engine is 2.8x faster with play proven identical: a route cache scoped to
one unchanged position, applyIntent split into prepareIntent + commitEvents,
and less allocation in exploreMoves. npm test now leaves out the bot
simulations, which run as npm run test:sim.

No rule changed; games in progress resume. Rejected candidates and the
Second Section card question are in CHANGELOG.md and TODO.md (#104-#106).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nnuCv8UodHucFfx3LWEoX
2026-09-15 15:30:42 -04:00
Jesse.MarkowitzandClaude Opus 5 072029b1f7 v0.8.0.8 — a played train does not come back; a discarded one does
Jesse's ruling on the question v0.8.0.7 filed: once a regularly scheduled train has
been played its number is on the timetable, so putting it back into a reshuffled
deck to be played again makes no sense. The same card sitting in a discard pile was
never played and its slot is still open, so it should come back. An Extra is a
single run rather than a standing slot, so a played one is free to run again.

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

And the duplicate that started it: trainScheduled was pushing a synthetic
train-<number> into the Salvage Yard beside the real card cardPlayed had already
put there — four scheduled trains left eight entries in a pile holding four cards.
Nothing read it, it inflated the depth, it displayed as "a card", and it would have
been swept into the draw deck to be drawn as an id with nothing behind it. Removed,
which retires the phantom-id class rather than papering over it, so v0.8.0.7's
cardName resolver for it goes too.

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

Closes #23.

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

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

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

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

That could have locked a player out of their own game. Hiding actions behind busy()
makes that flag the thing standing between a player and their turn, and without
requestAnimationFrame nothing ever advances the queue — so busy() would never
clear. Caught by the DOM-stub test that has been proving this page still starts
since long before any of this existed. No rAF now means draw everything at once,
which is what pace 0 does deliberately, and a queue that throws empties itself
rather than stranding anyone.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
2026-09-09 21:43:06 -04:00
49 changed files with 5665 additions and 307 deletions
+1085 -2
View File
File diff suppressed because it is too large Load Diff
+13 -2
View File
@@ -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
@@ -56,7 +65,8 @@ deliberately no longer names one: it went stale for six releases.
Balance is *not* where it should be, and this file no longer quotes a figure for it. It used to say
"the developer bot averages 7.0 Revenue against a target of 20", which stopped being true the moment
the transit rule it names was defaulted to off — that rule was worth ~5.4 of the 7.0, for traffic
nobody had to work. Measured at the current defaults the bot means about **zero**.
nobody had to work. Measured at the current defaults the bot meant about **zero** until it began
planning its switching turns (2026-09-14), which put it near **2.8**.
The three rates — passenger per coach, freight per load, train per transit — are **settings fixed when
the game is dealt**, along with the opening hand and where an Extra may start, so the economy can be
@@ -102,7 +112,8 @@ separate thing: it assembles the static SITE into `dist/`.)
```sh
npm install
npm test # node --test
npm test # node --test, everything except the bot simulations — run after every change
npm run test:sim # test/sim.test.ts, the bot simulations (~7 min) — run after a bot or balance change
npm run typecheck # tsc --noEmit
```
+308 -11
View File
@@ -79,9 +79,9 @@ 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** — #41 #57 #59 #53 #54 #58 #55 #56 #60
8. **The bot** — #104 #105 #106 #41 #57 #59 #54 #58 #55 #56 #60
9. **Code health and housekeeping** — #46 #45 #84 #87
10. **Documentation and assets** — #15a #86 #88
@@ -100,20 +100,119 @@ Everything else in this file waits behind a release; this waits behind an aftern
**Test runs WERE made across 0.7.4 through 0.7.9** (Jesse, 2026-09-07) and produced no change
requests — the two bugs that did come out of them are Gitea#21 and #22, fixed in v0.7.9.1. So this
section is not "nobody has touched it since 0.7.4"; it is the narrower and still-true claim that the
specific paths below have not been exercised at a table. **More testing is planned at the end of the
0.7.9 series, before 0.8.0 starts** — that is the moment to close these, not a separate errand.
specific paths below have not been exercised at a table.
**The gate moved.** It was "before 0.8.0 starts"; 0.8.0 shipped anyway, through v0.8.0.8, so the
session now runs against that build and covers what it added as well. See **Preparing the session**
below — written 2026-09-10 because the measurement it rests on is the whole point: **three of the
four things this section is named for do not happen by themselves.**
### Preparing the session
**MEASURED, 2026-09-10, across ten full competitive games driven to completion.** What a table will
meet without trying, and what it will not:
| interruption | fires in | so |
| --- | --- | --- |
| Superintendent clearance (§8.1) | **9/10 games** | you will meet it; just play |
| a train held at the Limits | 7/10 | ditto |
| Extras started and queued | 10/10 | ditto |
| collisions | 7/10 | ditto |
| Red Flags set / spent | 7/10, 6/10 | ditto |
| **the Yard Office offer** | **0/10** | must be set up |
| **the Red Flag hold and its prompt** | **0/10** | must be set up |
| **extended play (`dayExtended`)** | **0/10** | must be set up |
Those last three are exactly what #39 and #35 are NAMED for. They are not broken — they are
conditional, and the conditions are these, read out of `advance.ts` rather than guessed:
- **Yard Office** (`advance.ts` ~1290) needs the destination district to contain a card carrying the
`yardOffice` **enhancement**, AND an arriving train with **no coach** in its consist, AND a usable
route. The bot never builds one, so **somebody has to build a Yard Office and then let a freight
train arrive.**
- **Red Flag hold** (`advance.ts` ~1232) needs the destination player to be **holding the Red Flags
maneuver card**, AND an arrival that would genuinely collide — §8.3's own two ways: no free A/D
track, or cars fouling the Running Track. So: **hold that card and let your A/D tracks fill.**
- **Extended play** needs the timetable to RUN OUT, which a five-Day game does not do. Deal it with
**`days: 1`** — that is exactly what the 2026-08-29 API verification did, and why it got there.
**What the session needs**
- **Two people, two browsers, two devices.** #35's remaining gap is specifically what a SECOND
player sees while waiting on a first, and whether "waiting on Carol" still reads once Carol has
closed her laptop. That cannot be tested alone, and it is the half that has never been done.
- **Two games, not one.** A short `days: 1` game to reach the extension vote, and an ordinary game
for everything else — with somebody deliberately building a Yard Office and holding Red Flags.
- **#42a is separate and takes five minutes**, solitaire, one person: click every field on the setup
screen and confirm the dealt game matches what was chosen.
**The caution this section exists because of.** #35's own Reference entry records that the
2026-08-29 verification passed over the HTTP API — **which renders no dialog** — and that is exactly
why the v0.7.9 bug survived: the vote sat underneath a modal results dialog whose only control was
Close. What was proven was that the SERVER supports extended play, not that a player can reach it.
Read that into every "verified on `phoenix.local`" line in this file, and into everything v0.8.0
added, all of which is verified by test and simulation and none of it by eye.
**What v0.8.0 added to this list**, none of it played by a person for a whole game and none with a
second human: the watchable board and its ordered steps, the speed control, the pile highlighting and
the Home Office deck tile, "Your Move" being put away while catching up, the Day-end collision line,
and the Salvage Yard naming its top card.
### The checklist
Grouped by what has to be set up, with the item each observation closes. Nothing here needs a
developer present; what it needs is somebody writing down what they saw.
**Game A — `days: 1`, two humans, two browsers.** Reaches the extension vote in one Day.
- [ ] The vote appears **in front of both players**, not underneath the results dialog (#35 — this is
the exact shape of the bug v0.7.9 fixed).
- [ ] While one player has not voted, the other's turn chart says **who** it is waiting on (#35).
- [ ] **Close the second laptop mid-vote.** Does the first player learn why nothing is happening, and
does "waiting on Carol" still read once Carol is gone? (#35 — never tested.)
- [ ] Reopen it. The history panel comes back **populated**, not empty, and the board is current
(the v0.7.9.5 reconnect fix, never seen by a person).
- [ ] Vote yes. The extra Day begins and the official result is **unchanged** from when the
timetable ran out (#35).
**Game B — ordinary length, two humans, bots to fill.** Everything else.
- [ ] Somebody **builds a Yard Office** and lets a freight train (no coach) arrive at it. The offer
interrupts the Mainline Phase and asks a question mid-thought — is it legible, and does it say
which train? (#39)
- [ ] Somebody **holds the Red Flags card** while their A/D tracks are full, so an arrival would
collide. The hold is offered out of phase (#39).
- [ ] A **loaded Extra** is made up and run (#39 — the third of its three).
- [ ] Watch a bot take a whole turn: does the district follow it, does the lit pile catch the eye,
does the caption say who and what? (v0.8.0)
- [ ] Find the speed that suits you and say what it is — it becomes the committed default.
- [ ] Let the board fall behind, then press **Skip**. Nothing is lost; the history has it all.
- [ ] End a Day with a collision on it: the summary reads "N on Day D, N in all" and cannot
contradict itself (v0.8.0.2).
**Solitaire, five minutes, alone.**
- [ ] Click through **every field** on the setup screen and confirm the dealt game matches what was
chosen (#42a).
**Whatever else happens.** The two bugs that came out of the 0.7.4-0.7.9 runs were both things
nobody set out to test. Write down anything that reads wrong, even where the rule underneath is
right — most of this release's defects were legible-but-wrong rather than broken.
- [ ] **#39** — **None of v0.7.4 has been played by a human.** The Yard Office offer, the Red Flag
hold and its out-of-phase prompt, and the loaded-Extra make-up rules are tested end to end,
packed, and running on `phoenix.local` — and nobody has met any of them at a board. **Two are
interruptions that stop the Mainline Phase and put a question in front of somebody
mid-thought**, which is exactly the kind of thing only play reveals. See **Reference · #39**.
mid-thought**, which is exactly the kind of thing only play reveals. **Neither of those two
happens by itself — 0/10 games. See Preparing the session above for what to set up.** See
**Reference · #39**.
- [ ] **#35** — **Extended play has never been played at a real table.** It was verified over the HTTP
API, which renders no dialog — and when a human first reached it in a browser it was unusable
(fixed in v0.7.9). The multiplayer vote has still never been driven through two browsers: what a
second player sees while waiting, and whether "waiting on Carol" reads once Carol has closed her
laptop, are unanswered. See **Reference · #35**.
laptop, are unanswered. **Needs a `days: 1` game — the timetable does not run out in five
Days, so extended play fired in 0/10 measured games.** See **Reference · #35**.
- [ ] **#42a** — **Nobody has clicked through the solitaire setup screen's own fields** and confirmed
the dealt game matches what was chosen. It took three attempts to become reachable at all —
@@ -308,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
@@ -368,21 +503,37 @@ v0.7.9's collision-floor change (#61).
The developer bot exists to measure the game, not to be a good opponent — so a bot weakness matters
when it stops a measurement being trustworthy. **Read #57 before tuning any weights.**
**Since 2026-09-14 the bot plans its whole switching turn** (`sim/switch-planner.ts`, +2.89 revenue a
game), **takes a face-up card only if it could play it** (+1.52), and **since 2026-09-15 lays track by
what the district can do afterwards** (`bestValuedLay`, +0.12 over 6400 seeds, run-arounds 9/60 → 22/60). Jesse's goal for it is better decisions in simulated runs AND at a real table, with no
non-player advantage — it reads the board, never the deck.
- [ ] **#104** — Weigh a switching turn against drawing and the Freight Agent. Letting the planned gain
gate switching on its own measured nothing (0.1, 0.25) or worse (0.5): `usefulSwitching` already
says yes exactly when a plan gains. What would matter is a VALUE for the other two options to
compare against, which the bot does not have. See **Reference · #104**.
- [ ] **#106** — The Extra trap: a full hand of Extras the A/D cap is holding back cannot be discarded,
so the next draw forces one into a full Office. All 23 train plays past the cap in 40 games were
this. Avoiding the draw measured nothing (−0.03) because it stalled development. See
**Reference · #106**.
- [ ] **#105** — Plan across more than one turn. Jesse is in favour, one turn first to see the impact —
which is now measured. Deferred for a conversation, not declined. See **Reference · #105**.
- [ ] **#41** — The bot never plays Red Flags — zero in 200 games since Gitea#19, and that is deck
luck rather than unwillingness. It takes the danger prompt unconditionally; what it never does
is plant a flag ON PURPOSE to buy a Stage for switching, which needs it to know it wants time.
See **Reference · #41**.
- [ ] **#57** — The bot's priorities are not the problem — measured across ten heuristic variations.
**Read this before tuning weights**; it is the argument that the ceiling is elsewhere. See
**Reference · #57**.
**Read this before tuning weights**; it is the argument that the ceiling is elsewhere. **Part of
"elsewhere" was choosing one Move at a time**: planning the whole switching turn was worth
+2.89 (t = 15.8) in the 2026-09-14 bot-tuning round. See **Reference · #57**.
- [ ] **#59** — The run-around is out of reach of any bot, and the deck is why — measured five ways.
See **Reference · #59**.
- [ ] **#53** — The bot does not know to bring an expedited train back to the station. See **Reference
· #53**.
- [ ] **#54** — The bot cannot spot a car at a stub industry, and the cut-ordering rules made that
visible. See **Reference · #54**.
@@ -1424,6 +1575,12 @@ measuring deck luck rather than reachability, and its comment now says so.
#### #57 — THE BOT'S PRIORITIES ARE NOT THE PROBLEM — measured.
**2026-09-14 — confirmed, and one ceiling found.** Reordering priorities still moves nothing; what
moved the bot was SEARCH. `sim/switch-planner.ts` plans the whole switching turn against a score of
where it ends, and measured +2.89 ± 0.18 (t = 15.79) over 1600 paired seeds — freight loads and
unloads 0.22 → 1.53, Cargo phases with a car spotted 5% → 18%. The "8% of Cargo phases" below was a
fact about how the bot switched, not only about the deck. See `CHANGELOG.md`, 0.8.0.9.
**THE BOT'S PRIORITIES ARE NOT THE PROBLEM — measured.** Ten heuristic variations, each paired
over 400+ seeds. Every reordering of what the bot prefers came out inside the noise; the only
@@ -1449,6 +1606,21 @@ prioritised better. What is left is the economy itself, which is a deck question
#### #59 — THE RUN-AROUND IS OUT OF REACH OF ANY BOT, AND THE DECK IS WHY — measu…
**2026-09-14 — part of "the deck is why" was the bot's own draw.** It was taking ~32 face-up trains and
industries a game it could not play and discarding them again, so the hand rarely held track. Taking
only playable cards (now the default) grew districts 14 → 24 cards and run-arounds 3/60 → 9/60. And
~13 of the ~16 track pieces a game were being laid by the draw turn's "play what is in hand" fallback
at the first legal square, not by `bestTrackLay`; holding them (−0.83) and placing them by
`bestTrackLay`'s score (+0.07, noise) both failed, so the next limit is that SCORING — it cannot tell a
piece that opens an industry site or advances a run-around from one that fills a square. The deck
measurements below were taken before any of this and should be re-read with it in mind.
**2026-09-15 — the scoring, fixed.** `bestValuedLay` scores the layout a lay leaves (reachable industry
sites, a closed run-around, ways off the main) instead of the piece: closed run-arounds in 22 of 60
districts against 9, +0.118 ± 0.029 revenue (t = 4.14, 6400 seeds). The run-around is now reachable
without changing the deck; what is left is a bot that can USE one, which needs more than one turn of
planning (#105).
**THE RUN-AROUND IS OUT OF REACH OF ANY BOT, AND THE DECK IS WHY — measured, five ways.**
"Teach the bot to plan across turns" was tried properly and does not work. Every attempt is
@@ -1490,6 +1662,8 @@ the end of the game, drawing the fault **26 times**. Not an engine bug — the m
exactly as designed — but a clear next bot heuristic: prefer ending a switching turn with any
expedited crew back on the Office square, at least once it has finished the work it went out for.
**CLOSED 2026-09-14** — see **Done · 53**.
#### #54 — THE BOT CANNOT SPOT A CAR AT A STUB INDUSTRY, and the cut-ordering rul…
**THE BOT CANNOT SPOT A CAR AT A STUB INDUSTRY, and the cut-ordering rules made that visible.**
@@ -1511,6 +1685,12 @@ pass should not read the drop as a deck problem.
#### #58 — The bot cannot get a crew next to an industry, so Flying Switch never…
**2026-09-14 — the premise is now a deck fact.** Flying Switch is dealt **0 copies** (not in sheet 5;
Jesse, 2026-08-26), so no bot can fire it: across 30 standard games none was ever drawn. The switching
planner searches the card by default, so it will be used the day it is dealt again. The reachability
sweep's exemption in `sim.test.ts` stays until then.
**The bot cannot get a crew next to an industry, so Flying Switch never fires.** Industries are
now stub-only and the bot places 2.23 a game (was 3.84), in districts averaging under two rows
@@ -1558,6 +1738,114 @@ of zero" over 400 games — but at 400 games the standard error is ±0.33, so a
have looked like nothing. They are nearly free to re-run now and at least one may have been
discarded wrongly.
#### #104 — WEIGH SWITCHING AGAINST THE OTHER TWO OPTIONS, not against a threshold.
Measured 2026-09-14, paired over 400 seeds against the planner: switching only when the planned gain
clears a threshold scored −0.33 at 0.5 (t = −5.06), −0.01 at 0.25, +0.06 at 0.1 (t = 1.79). At 0.5 it
refuses turns that only collect cars, which feed later deliveries; below that it agrees with
`usefulSwitching`. The choice that is still made by a fixed ladder is WHICH of §6's three options a
Stage goes to, and the planner can now put a number on one of them. The other two need numbers of
their own — what a draw is worth given the hand and the Departments, what stocking a box is worth given
the cars spotted — before the three can be compared. Also: planning at every Local Operations decision
costs a full search each time, so any version of this has to stay cheap.
**2026-09-15 — measured the ceiling first: there is almost none.** At 907 real Local Operations choices
(75 standard games, seeds outside the usual measurement range), every legal option was tried and the rest
of the game played out by today's bot, 4 times each with the HIDDEN parts reshuffled — the Home Office
deck order and future rolls — and the same reshuffles for every option, so the comparison is paired.
Grouped by the rule that made the choice, the value of each alternative against it:
| the ladder chose | times | switch instead | draw instead | Freight Agent instead |
| --- | --- | --- | --- | --- |
| draw — nothing urgent, develop | 494 | +0.08 ± 0.12 | — | +0.02 ± 0.04 |
| Freight Agent — feed the pipeline | 147 | +0.07 ± 0.06 | −0.01 ± 0.04 | — |
| switch — a train with work at the Office | 108 | — | −0.17 ± 0.10 | −0.25 ± 0.08 |
| draw — an Office upgrade in hand | 100 | +0.21 ± 0.16 (6) | — | −0.08 ± 0.06 |
| switch — walk the crew home | 47 | — | +0.22 ± 0.14 | −0.15 ± 0.08 |
| draw — a train card in hand | 11 | — | — | −0.45 ± 0.24 |
No rule has an alternative that is significantly better; where the table leans, the ladder is usually
the one that is right. So given how the bot plays each option once chosen, the choice itself is close to
optimal, and value functions for draw and Freight Agent have little to find. The one lean worth a look if
this is reopened is walking a stranded crew home (+0.22, t ≈ 1.6). The rollout tool is analysis only —
the bot never sees a rollout.
#### #106 — THE EXTRA TRAP — why the cap on committed trains still lets an Office overfill.
**2026-09-15 — re-measured under today's defaults, and most of it is not the Extra trap.** 20 "no free
A/D track" collisions in 60 standard games, −1.67 revenue a game. No train was held (§8.2 or clearance) in
the Stage before any of them, and only 8 of the 20 trains destroyed were Extras. **Six destroyed a train
of the same number as a Second Section run within the previous two Stages — and all 26 Second Sections
the bot ran in those games were an accident:** the New Train phase's "no car on offer" fallback takes
`options[0]`, and `legalActions` lists `newTrain.secondSection` ahead of the Extra starts, so whenever an
Extra was waiting to start the bot doubled the train due out instead. The Office never had an A/D track
to spare for one.
**A RULES QUESTION FOR JESSE, found on the way — not a bot matter.** Q9 (`implications.md`) defines the
Second Section as a CARD "played on a train that is due out", and `content.ts` defines `SECOND_SECTION`
with 1 copy — but `buildDeck` never deals it, and `check`'s `newTrain.secondSection` asks for no card in
hand. So any player may run a Second Section for free on every train due out. Either the card should be
dealt and required, or the free action is the intended rule and Q9's wording is stale.
Also measured and removed: starting an over-cap Extra where its run never reaches the Office (+0.10,
t = 1.82) — such a start was on offer at 1 of 25 over-cap starts.
**The accident is fixed** (2026-09-15, default): the New Train fallback takes a car, a pass or the
Extra's start, never `options[0]` — +0.32 ± 0.09 (t = 3.64), collisions 0.24 → 0.19. **Still open under
this item:** the forced Extra itself (a full, undiscardable hand of Extras), and the Second Section card
question above.
`choose` removes train-card plays from the options when `trainWouldOverfillTheOffice`, but yields if
that would leave nothing legal. It does leave nothing legal in one ordinary position: the hand is over
the limit (§6.2 requires reducing it) and every card in it is an Extra, which `keepReason` forbids
discarding. Measured 2026-09-14 after the face-up take rule: 23 of 196 train plays in 40 games were past
the cap, every one "play what is in hand" with four Extras held, and the worst seeds each lost 3-4
collisions to it. Declining the draw option in that position measured −0.03 ± 0.11 — collisions fell
0.26 → 0.20 but cards played fell 29.0 → 25.6. Better answers to try: play the Extra at the least
dangerous moment rather than the first, count WHEN each committed train is due at the Office instead of
how many there are, or keep the hand from filling with Extras in the first place.
#### #105 — PLAN ACROSS TURNS — the evidence so far, for the conversation.
For: one-turn planning already reaches most switching work (Cargo phases with a car spotted 5% → 18%),
and what it cannot do is exactly what spans a Stage — leave a car on a spur for the next crew, or start
a run-around and finish it later. The planner already scores staged wanted cars (+0.2), which is a
first, crude step in that direction.
Against, for now: everything between two switching turns is not the player's — a Mainline Phase, trains
arriving, a Load/Unload phase — so a second turn cannot be searched the way the first is without either
simulating those phases (arrivals are on the public timetable, but cars on arriving trains are not
known) or scoring the position between turns more cleverly. And search cost is already what sets the
test suite's running time. Cheapest next step if taken up: a better score for "what the next turn can
still reach", not a deeper search.
**2026-09-15 — the run-around measurement that bears on this.** A candidate that lays track by what the
district can do afterwards (`valueLays`) more than doubled closed run-arounds, 9/60 → 22/60, yet moved
revenue only +0.14 ± 0.06 (t = 2.58, 1600 seeds). Traced over 40 games: the one-turn planner DOES use
the loop — 63 of 131 plans in a district with one end a move on it, 35 run it both ways — but its planned
gain per turn is the same with a run-around as without (0.28 against 0.27). A run-around is for putting a
train's cars in a different order, which pays off in the turns after; a planner that looks one turn
ahead has no way to value it.
**2026-09-15 — two-turn planning, built and measured: it does not pay.** `planTwoTurns` kept the six best
ends of a switching turn, removed the trains that would highball in the Mainline Phase in between (on the
Office square and made up — their cars leave with them), reset the Moves, planned the next turn from each,
and chose by the position after departures plus 0.8 of what the next turn adds. Nothing hidden is read.
| version | revenue, 400 paired seeds | what went wrong |
| --- | --- | --- |
| first | −0.28 ± 0.11 (t = −2.58) | an expedited train left away drew 31 faults in one game: the fault is charged in the gap, which the discounted next turn "recovered"; trains left away 5.3 a game against 3.1 |
| with the gap fault charged in full and 0.5 a Stage per train left away | −0.14 ± 0.06 (t = −2.36) | trains still left away 4.8 a game; the "crew must get back to the Office" choice 4.2 a game against 2.7 |
Why a second turn has so little to find, measured over 30 standard games:
- only **49%** of switching turns have the same train in the district at the next Local Operations choice;
- **6.1 wanted cars a game** do leave aboard departing trains — but a further switching turn from those
exact positions could have spotted only **0.7** of them: most were never deliverable;
- the one-turn planner already gains no more with a run-around than without (0.28 against 0.27).
And what a second turn COSTS is a Stage: trains left away have to be walked home, and those choices come
out of drawing and the Freight Agent (cards played 28.8 → 28.3). A multi-turn bot would have to weigh
switching against the other two options — which is #104, not a deeper search.
### Code health and housekeeping
#### #46 — tsc --noUnusedLocals finds 29 unused declarations across 14 files, and…
@@ -1801,6 +2089,15 @@ where it belongs, and it is still open.
Closed items, kept because several are the only record of a ruling or a lesson. Newest first within
each group.
### Closed in the 2026-09-14 bot-tuning round (unreleased)
53. ~~**The bot did not know to bring an expedited train back to the station.**~~ — done 2026-09-14,
not by a heuristic of its own but as a consequence of planning the switching turn: the planner's
score charges an expedited train left away from the Office a full Revenue point, which is what Q3
charges. Over 400 paired seeds, 19 games drew expedite faults under the rule ladder and **none**
under the planner, worth +1.07 a game (t = 3.83) — the two worst cases had drawn 45 and 42 faults
in a single game. See `CHANGELOG.md`, 0.8.0.9.
### Shipped through v0.7.9.8, from the queue
Closed items, newest first. Kept because several of them are the only record of a ruling or a lesson;
+28
View File
@@ -36,6 +36,34 @@ and `<ip>:<port>` are both expected — and browser storage is scoped to the ori
at one address must come back to that address, or they are a stranger with no token. Say so in the
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.
+3 -3
View File
@@ -888,7 +888,7 @@ Cover:
## Step 5 — Minimal visual-only Jitsi engine
> **v0.9.0 — DEFERRED (2026-09-09).** Steps 5-7 are the Jitsi publisher and are explicitly held
> off until 0.8.0 ships and Chromium has been measured on `phoenix.local`. Nothing below has been
> off until 0.8.0 ships and Chromium has been measured on the target hardware. Nothing below has been
> re-verified against the current code; it was written 2026-08-27. Two things will have moved by
> the time it is picked up: the control channel is **SSE + POST, not a WebSocket** (§ THE RELEASE
> SPLIT), and adding Chromium takes the `.s9pk` from 63 MB to several hundred against a package
@@ -1028,7 +1028,7 @@ Port/adapt the sibling repository’s proven tests for:
## Step 6 — Chromium publisher supervisor
> **v0.9.0 — DEFERRED (2026-09-09).** Steps 5-7 are the Jitsi publisher and are explicitly held
> off until 0.8.0 ships and Chromium has been measured on `phoenix.local`. Nothing below has been
> off until 0.8.0 ships and Chromium has been measured on the target hardware. Nothing below has been
> re-verified against the current code; it was written 2026-08-27. Two things will have moved by
> the time it is picked up: the control channel is **SSE + POST, not a WebSocket** (§ THE RELEASE
> SPLIT), and adding Chromium takes the `.s9pk` from 63 MB to several hundred against a package
@@ -1161,7 +1161,7 @@ Use fake child processes and fake control sockets to test:
## Step 7 — Configuration, lifecycle, packaging, and observability
> **v0.9.0 — DEFERRED (2026-09-09).** Steps 5-7 are the Jitsi publisher and are explicitly held
> off until 0.8.0 ships and Chromium has been measured on `phoenix.local`. Nothing below has been
> off until 0.8.0 ships and Chromium has been measured on the target hardware. Nothing below has been
> re-verified against the current code; it was written 2026-08-27. Two things will have moved by
> the time it is picked up: the control channel is **SSE + POST, not a WebSocket** (§ THE RELEASE
> SPLIT), and adding Chromium takes the `.s9pk` from 63 MB to several hundred against a package
+5 -5
View File
@@ -84,14 +84,14 @@ part of the road all change the entry point rather than the card's length.
The Mainline deck is 10 cards; the two Division Points are the fixed ends of
the 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.
---
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "station-master",
"version": "0.8.0.3",
"version": "0.8.0.14",
"private": true,
"type": "module",
"description": "Station Master — a railroad operations game",
@@ -10,7 +10,9 @@
"scripts": {
"typecheck": "tsc --noEmit",
"pretest": "tsc --noEmit && node scripts/build-web.ts",
"test": "node --test test/*.test.ts test/**/*.test.ts",
"test": "node --test $(ls test/*.test.ts test/**/*.test.ts | grep -v '^test/sim.test.ts$')",
"pretest:sim": "tsc --noEmit",
"test:sim": "node --test test/sim.test.ts",
"build:web": "node scripts/build-web.ts",
"serve:web": "node scripts/build-web.ts && npx --yes http-server dist -p 8080 -c-1",
"deploy:web": "node scripts/deploy-web.ts",
+10 -4
View File
@@ -70,11 +70,17 @@ function buildStamp(): string {
* and v0.7.6's fix to it both shipped correctly to `phoenix.local` and neither reached the browser
* 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()
+84 -4
View File
@@ -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';
@@ -94,6 +95,18 @@ function nodeIndexOfOffice(s: GameState, seat: SeatIndex): number {
const step = (d: Direction): number => (d === 'east' ? 1 : -1);
/** The Division node a train is standing on — its Office, its Mainline card, or its Division Point. */
function nodeIndexOfTray(s: GameState, tray: CrewTray): number | null {
const at = tray.position;
if (at.at === 'grid') return nodeIndexOfOffice(s, at.seat);
if (at.at === 'mainline') return at.index;
if (at.at === 'divisionPoint') {
const side = at.side;
return s.division.nodes.findIndex((n) => n.kind === 'divisionPoint' && n.side === side);
}
return null;
}
// ---------------------------------------------------------------------------
// advance
// ---------------------------------------------------------------------------
@@ -381,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 };
}
@@ -522,14 +574,17 @@ type MoveOutcome = 'moved' | 'held' | 'needsClearance';
*
* This is reachable purely through switching. A train arrives made up, and only comes apart because
* the player took cars onto the nose or picked up a cut in a run-around.
*
* Exported for the switching planner (`sim/switch-planner.ts`), which has to know whether a plan
* leaves a train unable to run — and must ask this rule rather than keep a copy of it.
*/
function badlyMadeUp(tray: CrewTray): string | null {
export function badlyMadeUp(tray: CrewTray): string | null {
const n = tray.consist.length;
if (n === 0) return 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;
@@ -1064,10 +1119,27 @@ function evaluateClearance(
* constrained. Each Office upgrade to a Control Point splits one in two and buys capacity.
*/
const subdivision = subdivisions(s).find((group) => group.includes(targetIndex)) ?? [targetIndex];
/**
* ONLY WHAT IS AHEAD (Gitea#26). §8.1 asks about a train the considered train would FOLLOW, and one
* moving TOWARDS it — both of which are ahead of it. A Subdivision runs the length of every Whistle
* Post between two Control Points, so it can hold a train BEHIND the one departing: in the reported
* game X15 highballed west from an Office while X18, also westbound, was still crossing the card to its
* east. Counting X18 put a meaningless ruling to the Superintendent; holding X15 kept the Whistle Post's
* one A/D track full, and X18 arrived into it and was destroyed. A train behind and moving away is no
* threat at all.
*
* "Behind" is strictly behind the card the departing train stands on. A train on that same card is still
* counted, exactly as before: which of two trains sharing a card is in front is `entryConflict`'s region
* question, and this is not the place to answer it.
*/
const from = nodeIndexOfTray(s, tray);
const behind = (onCard: number): boolean =>
from !== null && from >= 0 && (tray.direction === 'east' ? onCard < from : onCard > from);
const occupants: { tray: TrayId; onCard: number }[] = [];
for (const i of subdivision) {
const n = s.division.nodes[i];
if (!n || n.kind !== 'mainline') continue;
if (behind(i)) continue;
for (const t of n.transits) if (t.tray) occupants.push({ tray: t.tray, onCard: i });
}
@@ -1452,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),
});
@@ -1623,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.
+204 -29
View File
@@ -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
@@ -1508,17 +1531,48 @@ export function selectDestination(
return chosen ?? atTo[0];
}
/**
* ROUTES, WALKED ONCE PER POSITION.
*
* A route walk (`reachableDestinations`) was a third of all simulation time, and most of it was the
* same walk repeated: `legal.ts` walks a tray's routes to list its moves, then `check` walks them again
* for every one of those moves, and `applyIntent` walks the chosen one a third time in `execute`.
* Profiled 2026-09-14 with inlining off: `reachableDestinations` 34% inclusive, garbage collection 34%.
*
* So while one position is being examined — a legal-action listing, or the check and execute of one
* intent — a walk is kept and reused. Both scopes read the state and never write it, the key names
* everything the walk depends on besides that state, and the cache is keyed to the state OBJECT and
* cleared when the scope ends, so a hit returns exactly what a fresh walk would have. Nothing may
* mutate a returned route; nothing does.
*/
let routeCache: { state: GameState; routes: Map<string, MoveDestination[]> } | null = null;
export function withRouteCache<T>(s: GameState, fn: () => T): T {
if (routeCache) return fn();
routeCache = { state: s, routes: new Map() };
try {
return fn();
} finally {
routeCache = null;
}
}
function destinationsFor(
s: GameState,
player: PlayerIndex,
trayId: TrayId,
from: GridCoord,
reverse: boolean,
) {
): MoveDestination[] {
const cache = routeCache?.state === s ? routeCache.routes : null;
const key = cache ? `${player}|${trayId}|${from.row},${from.col}|${reverse ? 1 : 0}` : '';
const hit = cache?.get(key);
if (hit) return hit;
const tray = s.trays.get(trayId)!;
const facing = facingPort(s, trayId);
const exit: Port = reverse ? reversePort(s, player, from, facing) : facing;
return reachableDestinations(
const routes = reachableDestinations(
{
area: areaOf(s, player),
occupancy: occupancyFor(s, player, trayId),
@@ -1528,6 +1582,8 @@ function destinationsFor(
from,
exit,
);
cache?.set(key, routes);
return routes;
}
/**
@@ -1647,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.
*
@@ -1742,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' }];
@@ -1856,7 +1933,15 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
? REALIGNMENTS.find((r) => r.from === node.card)?.to
: undefined;
return [
{ type: 'mainlineModified', player, cardId: i.cardId, node: i.node, key, ...(became ? { became } : {}) },
{
type: 'mainlineModified',
player,
cardId: i.cardId,
node: i.node,
key,
...(node?.kind === 'mainline' ? { from: node.card } : {}),
...(became ? { became } : {}),
},
];
}
@@ -1915,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 }];
@@ -2100,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);
@@ -2178,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);
@@ -2242,7 +2362,8 @@ export function reduce(s: GameState, e: GameEvent): void {
// Everything swept comes back as ONE pile, then §4.6-4.7's opening is re-run: three cards
// turned face up as the Departments, the rest face down as the Home Office deck. The
// Departments start one deep again, exactly as at setup.
s.decks.salvageYard = [];
// The spent trains stay where they are; everything else in the Yard has just been swept up.
s.decks.salvageYard = s.decks.salvageYard.filter((id) => isSpentTimetabledTrain(s, id));
s.decks.departments = [[], [], []];
const order = [...e.order];
for (const pile of s.decks.departments) {
@@ -2473,7 +2594,15 @@ export function reduce(s: GameState, e: GameEvent): void {
case 'trainScheduled':
s.timetable[e.slot] = e.trainNumber;
s.rngState = e.rngState;
s.decks.salvageYard.push(`train-${e.trainNumber}`);
/**
* THE CARD IS ALREADY IN THE SALVAGE YARD — `cardPlayed` put it there, by its real id.
*
* This used to push a second, SYNTHETIC `train-<number>` beside it, so scheduling four trains
* left eight entries in a pile holding four cards. Nothing ever read that id: it inflated the
* pile's depth, it displayed as "a card" because no such card exists, and
* `reshuffleIfDepleted` would have swept it into the draw deck to be drawn as an id with
* nothing behind it. Removed 2026-09-10 (Gitea#23).
*/
break;
case 'carPlacedOnTrain': {
@@ -2693,7 +2822,8 @@ export function reduce(s: GameState, e: GameEvent): void {
* index is out of range, which `check` reports rather than silently defaulting — a wrong
* orientation is a different card, not a detail.
*/
function protoCard(
/** Exported for the same reason as `extendLimitsIfNeeded`: the bot builds the card a lay would place exactly as the reducer does. */
export function protoCard(
kind: { kind: string; geometry?: string; facility?: string; hand?: string },
variant: number | undefined,
): TrackCard | null {
@@ -2930,9 +3060,34 @@ function spendCard(s: GameState, player: PlayerIndex, cardId: CardId): void {
* that has genuinely used every card ends on `DECK_EMPTY` rather than reshuffling an empty pile.
* Cards played onto the board are NOT recovered: they are on the table, which is where they belong.
*/
/**
* §6.2, AND THE RULING THAT SETTLES IT — Jesse, 2026-09-10 (Gitea#23).
*
* "Once you've played a regularly scheduled train and it's in the salvage deck, that train is
* already on the timetable. It does not make sense to put that back into a reshuffled home deck to
* get played again. By contrast, a regularly scheduled train that's in a discard pile could
* potentially get reused later, and so should have that capability. Extras run one time and then
* they're done — if they are in the Salvage deck, they should get shuffled back in so that they
* could get run again."
*
* So the test is WHERE the card is, not only what it is. A timetabled train in the SALVAGE YARD was
* played: its number is on the timetable and cannot be scheduled twice, so the card is spent and
* stays out. The same card sitting in a DEPARTMENT was discarded, never played, and its slot is
* still open — so it comes back with everything else. An Extra is a single run rather than a
* standing slot, so a played one is free to be run again.
*/
function isSpentTimetabledTrain(s: GameState, id: CardId): boolean {
return s.cards.get(id)?.kind.kind === 'timetabledTrain';
}
function reshuffleIfDepleted(s: GameState, taking: number): GameEvent | null {
if (s.decks.homeOffice.length > taking) return null;
const collected = [...s.decks.salvageYard, ...s.decks.departments.flat()];
const collected = [
// The Salvage Yard, less the trains whose slots are already filled — see above.
...s.decks.salvageYard.filter((id) => !isSpentTimetabledTrain(s, id)),
// Every Department in full: a discarded train was never played, so it is still runnable.
...s.decks.departments.flat(),
];
if (collected.length === 0) return null;
const rng = createRng(s.rngState);
return { type: 'deckReshuffled', order: rng.shuffle(collected), rngState: rng.getState() };
@@ -3059,7 +3214,8 @@ function applyModifier(area: OfficeArea, coord: GridCoord, modifier: ModifierKin
* §8.1 and §10 both reason about "the track between the train and the Limits", Interlocking holds
* an arrival AT the Limits, and running past a player's Limits is what makes a collision his fault.
*/
function extendLimitsIfNeeded(area: OfficeArea, placed: GridCoord): void {
/** Exported so the bot can score a lay on a copy of the district by the engine's own rule, not a copy of it. */
export function extendLimitsIfNeeded(area: OfficeArea, placed: GridCoord): void {
if (placed.row !== area.runningRow) return;
if (placed.col <= area.limitsWest.col) {
@@ -3253,16 +3409,35 @@ function limitsCard(): TrackCard {
// Public entry point
// ---------------------------------------------------------------------------
export function applyIntent(s: GameState, player: PlayerIndex, i: Intent): ApplyResult {
const code = check(s, player, i);
if (code) return { ok: false, code, message: `${i.type} rejected: ${code}` };
/**
* THE FIRST HALF OF `applyIntent`: decide, without changing anything.
*
* `check` and `execute` read the same unchanged position, so its routes are walked once between them
* (`withRouteCache`). Never writes `s`. Split out for a caller that decides many intents against ONE
* position and applies each to a COPY of it — the switching planner — which can then share that
* position's routes across every candidate instead of re-walking them on each copy.
*/
export function prepareIntent(s: GameState, player: PlayerIndex, i: Intent): ApplyResult {
const prepared = withRouteCache(s, (): { code: RejectionCode } | { events: GameEvent[] } => {
const code = check(s, player, i);
return code ? { code } : { events: execute(s, player, i) };
});
if ('code' in prepared) return { ok: false, code: prepared.code, message: `${i.type} rejected: ${prepared.code}` };
return { ok: true, events: prepared.events };
}
const events = execute(s, player, i);
/** THE SECOND HALF: fold events `prepareIntent` produced into a state equal to the one it read. */
export function commitEvents(s: GameState, events: readonly GameEvent[]): void {
for (const e of events) reduce(s, e);
// Gitea#16 — the intent half of the fold; `advance` does the phase driver's half. See `tally.ts`
// for why it cannot simply live inside `reduce`.
for (const e of events) tallyEvent(s, e);
return { ok: true, events };
}
export function applyIntent(s: GameState, player: PlayerIndex, i: Intent): ApplyResult {
const r = prepareIntent(s, player, i);
if (r.ok) commitEvents(s, r.events);
return r;
}
export { isOperationalRail, destinationsFor };
+11 -1
View File
@@ -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') {
+87 -6
View File
@@ -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
@@ -87,7 +128,13 @@ export type GameEvent =
| { type: 'deckReshuffled'; order: CardId[]; rngState: number }
/** `variant` is the chosen orientation (Gap 11); it must be replayable, so it rides the event. */
| { type: 'cardPlayed'; player: PlayerIndex; cardId: CardId; placement?: GridCoord; variant?: number }
| { type: 'mainlineModified'; player: PlayerIndex; cardId: CardId; node: number; key: string; became?: string }
/**
* `from` is the card's kind BEFORE the change, carried so the log can say what was realigned
* rather than only what it turned into (playtest, 2026-09-15: "it should state that the mainline
* card 3 curves was converted to plains"). Events are derived by replaying a save, never stored,
* so widening one strands nothing on disk.
*/
| { type: 'mainlineModified'; player: PlayerIndex; cardId: CardId; node: number; key: string; from?: string; became?: string }
/** §Q (Gitea#19) — a flag planted on one side of a district's Limits. */
| { type: 'redFlagsSet'; player: PlayerIndex; cardId: CardId; seat: SeatIndex; side: Direction }
/** §Q (Gitea#19) — the flag stopped a train and came down with it. One card, one train. */
@@ -153,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,
@@ -174,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
View File
@@ -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' }
+138 -58
View File
@@ -14,7 +14,7 @@
import type { CarType, Hand, TrackGeometry } from './content.ts';
import { enhancementRule, mainlineProfile } from './content.ts';
import { check, areaOf, destinationsFor } from './apply.ts';
import { check, areaOf, destinationsFor, withRouteCache } from './apply.ts';
import type { Intent } from './intents.ts';
import type { GameState, GridCoord, PlayerIndex } from './state.ts';
import { coordKey, seatOf } from './state.ts';
@@ -53,7 +53,142 @@ const CAR_TYPES: readonly CarType[] = ['coach', 'boxcar', 'reefer', 'hopper', 't
/** Every intent `player` may legally submit right now. */
export function legalActions(s: GameState, player: PlayerIndex): Intent[] {
return candidates(s, player).filter((i) => check(s, player, i) === null);
// One position, examined many times over: its routes are walked once (`withRouteCache`).
return withRouteCache(s, () => candidates(s, player).filter((i) => check(s, player, i) === null));
}
/**
* §6.1 — the switching half of the Local Operations candidates, in the order `legalActions` offers
* them. Split out so the switching planner (`sim/switch-planner.ts`) can ask for just these without
* `check` running over every draw and Freight Agent candidate at each of the thousands of positions it
* tries — that was about a quarter of all planning time. Still no rules here: `check` decides.
*/
function switchCandidates(s: GameState, player: PlayerIndex): Intent[] {
const out: Intent[] = [];
for (const [trayId, tray] of s.trays) {
if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue;
const from = tray.position.coord;
for (const reverse of [false, true]) {
const dests = destinationsFor(s, player, trayId, from, reverse);
// Grouped by destination square so `distinguishingVia` only ever compares routes that are
// actually racing for the same button — two routes to DIFFERENT squares need no `via` to
// tell apart, `to` already does that.
const byCoord = new Map<string, MoveDestination[]>();
for (const d of dests) {
const k = coordKey(d.coord);
(byCoord.get(k) ?? byCoord.set(k, []).get(k)!).push(d);
}
for (const group of byCoord.values()) {
for (const d of group) {
const via = group.length > 1 ? distinguishingVia(d, group) : undefined;
out.push({ type: 'switch.move', trayId, to: d.coord, reverse, ...(via ? { via } : {}) });
}
}
}
for (let n = 1; n <= tray.consist.length; n++) {
out.push({ type: 'switch.dropCars', trayId, count: n });
// Off the nose as well as the tail — the only way to get cars back off the front of a train
// 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.
*
* 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 = identity.filter((x) => x !== k);
order.push(k);
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 });
}
}
}
// Flying Switch — roll a cut into an ADJACENT industry without the engine entering it.
for (const cardId of s.decks.hands.get(player) ?? []) {
const k = s.cards.get(cardId)?.kind;
if (k?.kind !== 'maneuver' || k.key !== 'flyingSwitch') continue;
for (const [trayId, tray] of s.trays) {
if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue;
const from = tray.position.coord;
for (const reverse of [false, true]) {
for (const d of destinationsFor(s, player, trayId, from, reverse)) {
for (let count = 1; count <= tray.consist.length; count++) {
out.push({ type: 'maneuver.flyingSwitch', cardId, trayId, count, to: d.coord });
}
}
}
}
}
out.push({ type: 'switch.end' });
return out;
}
/** The switching intents `player` may legally submit right now — exactly `legalActions`' switching subset. */
export function legalSwitchingActions(s: GameState, player: PlayerIndex): Intent[] {
return withRouteCache(s, () => switchCandidates(s, player).filter((i) => check(s, player, i) === null));
}
export function isLegal(s: GameState, player: PlayerIndex, i: Intent): boolean {
@@ -138,62 +273,7 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
const area = areaOf(s, player);
// -- switch (§6.1)
for (const [trayId, tray] of s.trays) {
if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue;
const from = tray.position.coord;
for (const reverse of [false, true]) {
const dests = destinationsFor(s, player, trayId, from, reverse);
// Grouped by destination square so `distinguishingVia` only ever compares routes that are
// actually racing for the same button — two routes to DIFFERENT squares need no `via` to
// tell apart, `to` already does that.
const byCoord = new Map<string, MoveDestination[]>();
for (const d of dests) {
const k = coordKey(d.coord);
(byCoord.get(k) ?? byCoord.set(k, []).get(k)!).push(d);
}
for (const group of byCoord.values()) {
for (const d of group) {
const via = group.length > 1 ? distinguishingVia(d, group) : undefined;
out.push({ type: 'switch.move', trayId, to: d.coord, reverse, ...(via ? { via } : {}) });
}
}
}
for (let n = 1; n <= tray.consist.length; n++) {
out.push({ type: 'switch.dropCars', trayId, count: n });
// Off the nose as well as the tail — the only way to get cars back off the front of a train
// 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.
const n = tray.consist.length;
if (n > 1) {
for (let k = 0; k < n; k++) {
const order = [...Array(n).keys()].filter((x) => x !== k);
order.push(k);
out.push({ type: 'switch.sortConsist', trayId, order });
}
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.
for (const cardId of s.decks.hands.get(player) ?? []) {
const k = s.cards.get(cardId)?.kind;
if (k?.kind !== 'maneuver' || k.key !== 'flyingSwitch') continue;
for (const [trayId, tray] of s.trays) {
if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue;
const from = tray.position.coord;
for (const reverse of [false, true]) {
for (const d of destinationsFor(s, player, trayId, from, reverse)) {
for (let count = 1; count <= tray.consist.length; count++) {
out.push({ type: 'maneuver.flyingSwitch', cardId, trayId, count, to: d.coord });
}
}
}
}
}
out.push({ type: 'switch.end' });
out.push(...switchCandidates(s, player));
// -- draw (§6.2)
out.push({ type: 'draw.fromHomeOffice' });
+20
View File
@@ -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: {},
+47 -17
View File
@@ -377,7 +377,7 @@ export function reachableDestinations(
start: GridCoord,
initialExit: Port,
): MoveDestination[] {
return exploreMoves(ctx, start, initialExit).destinations;
return exploreMoves(ctx, start, initialExit, false).destinations;
}
/**
@@ -434,12 +434,19 @@ export function exploreMoves(
ctx: MoveContext,
start: GridCoord,
initialExit: Port,
/**
* False when only the destinations are wanted (`reachableDestinations`, every legality check): the
* rejections are then not recorded at all. They never change a destination, and building them was
* pure allocation on the hottest path in the engine.
*/
collectBlocks = true,
): { destinations: MoveDestination[]; blocked: MoveBlock[] } {
const { area, occupancy } = ctx;
const results: MoveDestination[] = [];
const blocked: MoveBlock[] = [];
const noted = new Set<string>();
const block = (coord: GridCoord, kind: MoveBlockKind, why: string): void => {
if (!collectBlocks) return;
const k = coordKey(coord);
if (noted.has(k)) return;
noted.add(k);
@@ -459,10 +466,25 @@ export function exploreMoves(
couples: RollingStock[];
/** Coord key each entry in `couples` came off, aligned by index — see `routeOutcomeKey`. */
origins: string[];
/** Cards visited on THIS route, start included. A per-path set, not a global one — see the
* module doc comment on `MAX_ENUMERATED_FRONTIER` for why a global one would forbid the very
* routes this walk exists to find. */
visited: Set<string>;
};
/**
* Has THIS route already used `to`? Per-path, not global — see the doc comment on
* `MAX_ENUMERATED_FRONTIER` for why a global set would forbid the very routes this walk exists to
* find.
*
* Read off the route's own `path` instead of a Set copied at every step, which was a large share of
* the walk's garbage. It answers exactly as that Set did: the start square, then every square
* enqueued along the route AFTER the first hop, including this node's own — the first hop's square
* was never added, and `path[0]` is that square, so the scan begins at 1.
*/
const onRoute = (node: Frontier, to: GridCoord): boolean => {
if (sameCoord(to, start)) return true;
if (node.path.length > 0 && sameCoord(to, node.coord)) return true;
for (let k = 1; k < node.path.length; k++) {
if (sameCoord(node.path[k]!.coord, to)) return true;
}
return false;
};
// The very first hop is checked here because `start`'s card is not itself enqueued; every later
@@ -493,13 +515,14 @@ export function exploreMoves(
path: [],
couples: ownCut,
origins: ownCut.map(() => startKey),
visited: new Set([startKey]),
},
];
let enumerated = 1;
while (queue.length > 0) {
const node = queue.shift()!;
// FIFO by index rather than `shift()`, which re-packs the array on every pop. Same order.
let head = 0;
while (head < queue.length) {
const node = queue[head++]!;
const card = cardAt(area, node.coord);
if (!card) continue;
@@ -592,17 +615,15 @@ export function exploreMoves(
// direction; it never says without repeating ground, but a train cannot occupy the same
// track twice at once either). Per-path, not global — a DIFFERENT route may legitimately
// pass through a card this one already used.
const toKey = coordKey(to);
if (node.visited.has(toKey)) continue;
if (onRoute(node, to)) continue;
if (enumerated >= MAX_ENUMERATED_FRONTIER) break;
enumerated++;
const step: MoveStep = { coord: node.coord, entry: node.entry, exit };
const visited = new Set(node.visited);
visited.add(toKey);
queue.push({ coord: to, entry: opposite(exit), path: [...node.path, step], couples, origins, visited });
queue.push({ coord: to, entry: opposite(exit), path: [...node.path, step], couples, origins });
}
}
if (!collectBlocks) return { destinations: results, blocked };
// A card that turned out to be reachable after all is not a blocker: the walk may meet a square
// from a bad angle first and a good one later.
const reached = new Set(results.map((r) => coordKey(r.coord)));
@@ -663,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;
+83
View File
@@ -0,0 +1,83 @@
/**
* SEAT RECOVERY CODES — Gitea#33.
*
* A session token is the only identity the game has (`lobby-and-sessions.md` §1) and it lives in
* exactly one place the player controls: their browser's `localStorage`, scoped to the origin they
* joined at. Lose that — a different browser, a cleared profile, a private window — and the seat is
* unreachable, because there is nothing else on the server that will accept a claim to it. Seen at a
* real table on 2026-09-16: the joining player came back to an empty lobby while their token sat
* intact in `sessions.json`, and the only way in was an administrator reading the file off the data
* volume and the player pasting it into a devtools console.
*
* THE CODE IS NOT THE TOKEN, AND THAT IS THE WHOLE POINT. §1 says to keep the token out of URLs so it
* is not shoulder-surfed or pasted into a chat — and a recovery link is exactly the kind of thing
* that gets pasted into a chat. So an administrator mints a SHORT-LIVED, SINGLE-USE code, the player
* opens a link carrying that, and the page trades it for the real token over the same connection it
* would have used anyway. A code that leaks after it is spent is worth nothing; a token that leaks is
* worth the seat for the rest of the game.
*
* PURE ON PURPOSE, like `lobby.ts` beside it: no sockets, no filesystem, no clock of its own. `now`
* is passed in so expiry is testable without faking timers, which is the only reason this file can be
* tested at all — nothing in this repo stands an HTTP server up to make requests against it.
*
* IN MEMORY, NOT ON DISK, which is a deliberate limit rather than an oversight. A restart drops every
* outstanding code, and that is the right failure: the codes are minted on demand and spent within
* minutes, the administrator is by definition present, and persisting them would put a credential-
* equivalent on the volume to solve a problem measured in seconds.
*/
import { randomUUID } from 'node:crypto';
/**
* Long enough to walk to the other room and read it out; short enough that a link left in a chat
* window is useless by the time anyone scrolls back to it.
*/
export const CLAIM_TTL_MS = 30 * 60 * 1000;
export type ClaimStore = {
/** Mint a code for one seat's token. Returns the code and when it stops working. */
mint(token: string, gameId: string, now: number, ttlMs?: number): { code: string; expiresAt: number };
/**
* Spend a code. Returns the seat it names, or null when the code is unknown, already spent or
* expired — deliberately one answer for all three, so a caller cannot probe which it was.
*/
redeem(code: string, now: number): { token: string; gameId: string } | null;
/** Outstanding, unexpired codes. For tests and for anything that wants to report the store's size. */
outstanding(now: number): number;
};
export function createClaimStore(): ClaimStore {
const claims = new Map<string, { token: string; gameId: string; expiresAt: number }>();
/** Expiry is lazy: there is no timer to own, start, stop or leak across a server's lifetime. */
const prune = (now: number): void => {
for (const [code, claim] of claims) if (claim.expiresAt <= now) claims.delete(code);
};
return {
mint(token, gameId, now, ttlMs = CLAIM_TTL_MS) {
prune(now);
// The same primitive the session tokens themselves use (`lobby.ts`), for the same reason: it
// has to be unguessable, and inventing a second scheme here would be inventing a weaker one.
const code = randomUUID();
const expiresAt = now + ttlMs;
claims.set(code, { token, gameId, expiresAt });
return { code, expiresAt };
},
redeem(code, now) {
prune(now);
const claim = claims.get(code);
if (!claim) return null;
// SINGLE USE. Deleted before the caller can do anything with it, so two browsers racing on the
// same link cannot both be seated — and a link that stays in someone's history is spent.
claims.delete(code);
return { token: claim.token, gameId: claim.gameId };
},
outstanding(now) {
prune(now);
return claims.size;
},
};
}
+108 -2
View File
@@ -37,6 +37,7 @@ import {
writeLobby,
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,57 @@ 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").
*
* The administrative export at `/api/games/<id>/save` is gated on the admin secret, which a player
* does not have and should not need: a save is the seed and the moves, and every one of those moves
* is already on this player's screen. So the seat's own session token is the gate, exactly as it is
* for `/api/stream` and `/api/intent` — it proves which game and which chair, and nothing else is
* disclosed. The page turns the JSON into a file (`main.ts`'s `downloadSave`).
*/
if (url.pathname === '/api/save' && req.method === 'GET') {
const ps = sessions.get(url.searchParams.get('token') ?? '');
const session = ps ? games.get(ps.gameId) : undefined;
if (!ps || !session) {
sendJson(res, 404, { error: 'no such game' });
return;
}
sendJson(res, 200, { gameId: ps.gameId, save: session.exportSave() });
return;
}
if (url.pathname === '/api/stream' && req.method === 'GET') {
const token = url.searchParams.get('token') ?? '';
const ps = sessions.get(token);
+56 -8
View File
@@ -42,6 +42,12 @@ export type DivisionRoster = {
actor: number | null;
/** The player this map is being drawn for. */
viewer: number;
/**
* Division nodes to flash — a Mainline card that has just become a different card (Realignment).
* Playtest, 2026-09-15: the log said a card had been converted and the map said nothing, so the one
* play that changes the Division itself was invisible on the map of it.
*/
flash?: readonly number[];
};
export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | null): string {
@@ -131,9 +137,13 @@ 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;
/** Set on a card that has just changed under the players' feet — drawn with a brief pulse. */
flash?: boolean;
/** Which SEAT's district this cell belongs to, or null for Mainline and Division Points. */
seat: number | null;
/** Set on an Office cell when a roster was supplied: whose district this is. */
@@ -168,7 +178,11 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
cells.push({ ...c, x: 0, y: 0 });
};
// The node's own index, so a cell can be matched against `roster.flash`. `continue` below skips the
// rest of the body, never this.
let nodeIndex = -1;
for (const n of nodes) {
nodeIndex++;
if (n.kind === 'office') {
const cap = n.capacity;
const ad = n.trains.flat();
@@ -261,6 +275,7 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
push({
kind: dp ? 'dp' : 'ml',
label: n.label,
...(roster?.flash?.includes(nodeIndex) ? { flash: true } : {}),
sub: n.capacity === null
? 'no limit — trains queue'
: [free, inYard.length > 0 ? `${inYard.length} in the yard` : ''].filter(Boolean).join(' · '),
@@ -356,7 +371,7 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
cells.forEach((c) => {
const full = c.cap !== null && c.trains.length >= c.cap;
out += `<g class="bs-dcell bs-d${c.kind}${full ? ' bs-full' : ''}" data-tip="${esc(c.tip)}">`;
out += `<g class="bs-dcell bs-d${c.kind}${full ? ' bs-full' : ''}${c.flash ? ' bs-changed' : ''}" data-tip="${esc(c.tip)}">`;
out += `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${CH}" rx="5"/>`;
/**
* WHOSE IS IT, IS IT THEIR MOVE, AND IS IT MINE — answered by colour and one suffix rather
@@ -502,13 +517,18 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
const arrow = t.facing === 'w' ? '\u25c0' : '\u25b6';
const 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
@@ -669,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.
@@ -1205,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');
}
@@ -1248,9 +1280,18 @@ export const BOARD_CSS = `
and leave at the other, and a seated layout must not be read as a ring. */
.bs-stop line{stroke:#e0a060;stroke-width:2.6;stroke-linecap:round}
.bs-end{fill:#e0a060;font:10px ui-monospace,monospace;letter-spacing:.03em}
/* The vertical bars a Mainline card is divided into (§2.1). Drawn faint: they are the ruler the
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}
/* A card that has just BECOME a different card (Realignment). The same amber the rest of the page
spends on "it is happening here", pulsing only while the step that did it is on screen — so the
change is seen on the map rather than only read in the log. */
.bs-dcell.bs-changed rect{stroke:#e0a060;stroke-width:2.4;animation:bs-changed-pulse 1.1s ease-in-out infinite}
@keyframes bs-changed-pulse{0%,100%{stroke-opacity:1}50%{stroke-opacity:.35}}
@media (prefers-reduced-motion: reduce){.bs-dcell.bs-changed rect{animation:none}}
/* The vertical bars a Mainline card is divided into (§2.1). They are the ruler the train is measured
against, not something to look at instead of the train — but they were drawn so faint they could
not be made out at all (Jesse, playtest 2026-09-16: "the dividing line is barely visible"). A
ruler you cannot read is not restraint, so this is lifted to the tie colour and given a longer
dash: still quieter than the rail, and now actually there. */
.bs-region{stroke:#98a3b2;stroke-width:1.6;stroke-dasharray:4 2}
/* #94 — the one red mark on the Division map, so it reads as a stop rather than as decoration. */
.bs-flag line{stroke:#9aa3b0;stroke-width:1.6}
.bs-flag polygon{fill:#d2453f;stroke:#7d211d;stroke-width:0.8}
@@ -1283,6 +1324,10 @@ export const BOARD_CSS = `
.bs-slot.bs-car-cch.bs-loaded{fill:rgba(90,169,230,.85)}
.bs-slot.bs-car-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. */
@@ -1329,7 +1374,10 @@ export const BOARD_CSS = `
.bs-arrow{fill:#5f6b7a;font:10px ui-monospace,monospace}
.bs-cn{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
.bs-coord{fill:#5f6b7a;font:9px ui-monospace,monospace}
.bs-name{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
/* stroke:none (Gitea#24). A name takes the class \`bs-turn\` while it is that player's move, and \`.bs-turn\` is
also the turn ARROW's rule, which strokes its shape 2.4px grey. Declared after it, this keeps that
outline off the letters, which it smeared into an unreadable blur. */
.bs-name{fill:#e6e9ee;stroke:none;font:600 11px ui-monospace,monospace}
.bs-name.bs-you{fill:#5aa9e6}
/* Their move — wins over .bs-you when both apply, because whose turn it is changes every few
seconds and which railroad is yours never does.
+238 -16
View File
@@ -22,6 +22,9 @@
import {
applyIntent,
areaOf,
extendLimitsIfNeeded,
isLockedOut,
protoCard,
canAdvanceLoad,
destinationsFor,
facilityCarTypes,
@@ -29,14 +32,15 @@ import {
ownCutFor,
} from '../engine/apply.ts';
import { MAX_CONSIST, nextOfficeTier, officeProfile } from '../engine/content.ts';
import type { CarType, Hand, TrackGeometry } from '../engine/content.ts';
import type { CarType, FreightKind, Hand, TrackGeometry } from '../engine/content.ts';
import type { GameEvent } from '../engine/events.ts';
import type { Intent } from '../engine/intents.ts';
import { legalActions } from '../engine/legal.ts';
import { connectionsFor, exitsFrom, facilityVariants, hasPort, joins, neighbour, opposite, variantsFor } from '../engine/track.ts';
import { canPlaceAt, connectionsFor, exitsFrom, facilityVariants, hasPort, joins, neighbour, opposite, variantsFor } from '../engine/track.ts';
import type { Port } from '../engine/track.ts';
import { actingPlayer, coordKey, turnOf } from '../engine/state.ts';
import type { Facility, GameState, GridCoord, OfficeArea, PlayerIndex, RollingStock, TrackCard } from '../engine/state.ts';
import { planSwitchingTurn, switchFingerprint } from './switch-planner.ts';
export type BotPolicy = {
name: string;
@@ -106,7 +110,8 @@ function because(reason: string, intent: Intent): Intent {
* Every flag here turns something OFF. That is the opposite of how this started — the tweaks were
* candidates to switch on — and it is the right shape once a candidate has been adopted: what a
* measured heuristic needs afterwards is a way to ask "is this still worth it?" when the deck or
* the rules move under it. Both of these were worth about +1.5 revenue together when adopted; if a
* the rules move under it. The first two were worth about +1.5 revenue together when adopted, and
* planning the switching turn (`noPlanSwitching`) +2.89 on its own; if a
* rebalance changes the economy, that is a claim to re-test rather than to assume.
*
* The candidates that did NOT survive are gone rather than left switched off: preferring coaches at
@@ -121,9 +126,78 @@ export type BotTweaks = {
noTrainCap?: boolean;
/** Draw whenever nothing is urgent, as the bot did before it preferred operating. */
noOperateFirst?: boolean;
/**
* Choose switching Moves one at a time from the rule ladder, as the bot did before it planned the
* whole turn (`switch-planner.ts`). Measured at adoption, 2026-09-14: planning was worth
* +2.89 ± 0.18 revenue a game (t = 15.79) over 1600 paired seeds, 733 better against 21 worse.
*/
noPlanSwitching?: boolean;
/**
* Take a face-up train or industry card whether or not it could be played, as the bot did before
* 2026-09-14. It then took 20.1 trains and 11.9 industries a game off the Departments and discarded
* 20.2 and 11.8, retaking the same card 28.8 times a game. Asking first measured +1.52 ± 0.10
* (t = 15.59) over 1600 paired seeds — this ablation was worse on 880 of them and better on 211.
*/
noPlayableTakes?: boolean;
/**
* Choose where track goes by `bestTrackLay`'s piece rules and the fallback's first legal square, as the
* bot did before 2026-09-15, instead of by what the district can DO afterwards (`bestValuedLay`).
* Scoring the layout measured +0.118 ± 0.029 (t = 4.14) over 6400 paired seeds, and closed run-arounds
* in 22 of 60 districts against 9.
*/
noValueLays?: boolean;
/**
* Let the New Train phase's fallback take `options[0]`, as the bot did before 2026-09-15. Because
* `legalActions` lists `newTrain.secondSection` ahead of the Extra starts, all 26 Second Sections the
* bot ran in 60 games were that accident, and 6 of the 20 collisions followed one. Taking a car, a pass
* or the Extra's start instead measured +0.32 ± 0.09 (t = 3.64) over 400 paired seeds.
*/
noDeliberateNewTrain?: boolean;
};
/**
* A switching turn planned once and then played a step per decision.
*
* Keyed by the tweaks object, because that is what one policy owns — the server shares a single
* `developerBot` across every bot seat, so the plan inside it is kept per player. Each step is
* submitted only while the position still matches the fingerprint the plan expected there; anything
* else replans. A switching turn has no randomness, so in practice a plan is made once a turn.
*/
type ActivePlan = { steps: Intent[]; keys: string[]; next: number; summary: string };
const activePlans = new WeakMap<BotTweaks, Map<PlayerIndex, ActivePlan>>();
function plannedSwitch(s: GameState, player: PlayerIndex, options: Intent[], tweaks: BotTweaks): Intent | null {
let mine = activePlans.get(tweaks);
if (!mine) activePlans.set(tweaks, (mine = new Map()));
const here = switchFingerprint(s, player);
let active = mine.get(player);
if (!active || active.keys[active.next] !== here) {
const p = planSwitchingTurn(s, player);
active = {
steps: p.steps,
keys: p.keys,
next: 0,
summary:
`position ${p.rootScore.toFixed(2)} → ${p.score.toFixed(2)} over ${p.expanded} positions` +
(p.complete ? '' : ', search budget reached'),
};
mine.set(player, active);
}
if (active.next >= active.steps.length) {
mine.delete(player);
const end = options.find((i) => i.type === 'switch.end');
return end ? because(`planned switching turn complete — ${active.summary}`, end) : null;
}
const want = JSON.stringify(active.steps[active.next]);
const match = options.find((i) => JSON.stringify(i) === want);
if (!match) {
mine.delete(player);
return null;
}
active.next++;
return because(`step ${active.next} of ${active.steps.length} of a planned switching turn — ${active.summary}`, match);
}
/** The bot as it plays today. Every knob off. */
export const developerBot: BotPolicy = makeDeveloperBot({});
@@ -209,6 +283,13 @@ export function makeDeveloperBot(tweaks: BotTweaks): BotPolicy {
);
if (match) return because(`the ${w.loaded ? 'loaded' : 'empty'} ${w.type} is what a facility is short of`, match);
}
if (!tweaks.noDeliberateNewTrain) {
const move =
pickFirst(options, 'newTrain.placeCar', 'newTrain.passCar', 'newTrain.startExtra') ??
options.find((i) => i.type !== 'newTrain.secondSection' && i.type !== 'maneuver.redFlags') ??
options[0]!;
return because('no car on offer is one our facilities need', move);
}
return because('no car on offer is one our facilities need', pickFirst(options, 'newTrain.placeCar', 'newTrain.passCar') ?? options[0]!);
}
@@ -438,7 +519,7 @@ function topOfDepartment(s: GameState, slot: number): string | undefined {
* In a competitive game the same call reads the other way round — burying a card a rival wants is an
* attack — which is why the choice belongs to the discarding player and not to the rules.
*/
function bestDiscard(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null {
function bestDiscard(s: GameState, player: PlayerIndex, options: Intent[], tweaks: BotTweaks): Intent | null {
let best: Intent | null = null;
let bestScore = -Infinity;
for (const i of options) {
@@ -448,7 +529,7 @@ function bestDiscard(s: GameState, player: PlayerIndex, options: Intent[]): Inte
// two showing whatever they happened to start with. Measured over 100 games — spreading 2.87
// revenue, concentrating on the deepest 2.67, indifferent 2.67.
const top = topOfDepartment(s, i.toSlot);
const wanted = isWorthTaking(s, player, i.toSlot);
const wanted = isWorthTaking(s, player, i.toSlot, tweaks);
const depth = s.decks.departments[i.toSlot]?.length ?? 0;
const score = (top === undefined ? 6 : wanted ? -10 : 2) - Math.min(depth, 4) * 0.5;
if (score > bestScore) {
@@ -460,10 +541,39 @@ function bestDiscard(s: GameState, player: PlayerIndex, options: Intent[]): Inte
}
/** A face-up card worth spending the draw on rather than gambling on the deck. */
function isWorthTaking(s: GameState, player: PlayerIndex, slot: number): boolean {
return takingRank(s, player, slot) > 0;
function isWorthTaking(s: GameState, player: PlayerIndex, slot: number, tweaks: BotTweaks): boolean {
return takingRank(s, player, slot, tweaks) > 0;
}
/**
* Could an industry of this kind be laid anywhere right now? Asked of the engine's own placement
* rule (`canPlaceAt`) and lockout (`isLockedOut`) rather than a copy: an industry is plain east-west
* track, so the only squares worth asking about are empty ones east or west of a card already down.
*/
function industrySiteExists(s: GameState, player: PlayerIndex, kind: FreightKind): boolean {
const area = areaOf(s, player);
if (isLockedOut(area, kind)) return false;
const probe = {
geometry: { kind: 'facility', facility: kind },
baseOperationalRail: true,
standing: [],
standingWest: 0,
facility: null,
modifiers: [],
enhancements: [],
} as unknown as TrackCard;
for (const key of area.grid.keys()) {
const [row, col] = key.split(',').map(Number);
for (const dc of [1, -1]) {
const at = { row: row!, col: col! + dc };
if (at.row === area.runningRow || area.grid.has(coordKey(at))) continue;
if (canPlaceAt(area, at, probe)) return true;
}
}
return false;
}
/**
* HOW BADLY the face-up card is wanted. 0 means not worth the draw.
*
@@ -471,14 +581,18 @@ function isWorthTaking(s: GameState, player: PlayerIndex, slot: number): boolean
* happened to be scanned first — a coin flip on the card that decides whether the district ever
* becomes a Passenger Facility at all.
*/
function takingRank(s: GameState, player: PlayerIndex, slot: number): number {
function takingRank(s: GameState, player: PlayerIndex, slot: number, tweaks: BotTweaks): number {
const id = topOfDepartment(s, slot);
if (!id) return 0;
const k = s.cards.get(id)?.kind;
if (!k) return 0;
if (k.kind === 'office') return nextOfficeTier(areaOf(s, player).tier) === k.tier ? 3 : 0;
if (k.kind === 'timetabledTrain' || k.kind === 'extraTrain') return 2;
if (k.kind === 'freightFacility') return 1;
if (k.kind === 'timetabledTrain' || k.kind === 'extraTrain') {
return !tweaks.noPlayableTakes && trainWouldOverfillTheOffice(s, player, tweaks) ? 0 : 2;
}
if (k.kind === 'freightFacility') {
return !tweaks.noPlayableTakes && !industrySiteExists(s, player, k.facility) ? 0 : 1;
}
return 0;
}
@@ -741,6 +855,104 @@ function bestFacilityPlay(s: GameState, player: PlayerIndex, options: Intent[]):
return best;
}
/**
* WHAT A DISTRICT'S TRACK IS WORTH FOR WHAT IT LETS HAPPEN NEXT — the default since 2026-09-15;
* `noValueLays` turns it off.
*
* `bestTrackLay` scores the PIECE — its shape and where it sits — and cannot tell one that opens an
* industry site or closes a run-around from one that merely fills a square. This scores the LAYOUT the
* piece would leave, so a lay is worth the difference it makes. Every term is something the rules turn
* into play: a site is somewhere a held industry can go; a run-around lets a crew pass its own cars
* (§A.5); a way off the main is the only road to either; a Running Track straight is what Interlocking
* needs. The weights are a starting point to measure, not a result.
*/
function layoutValue(area: OfficeArea): number {
let v = 0;
const reachable = reachableOffMain(area);
v += Math.min(reachable.size, 12) * 0.2;
let ways = 0;
let loops = 0;
for (const side of SIDES) {
for (const col of waysOff(area, side)) {
ways++;
if (descendFrom(area, col, side).rejoins.size > 0) loops++;
}
}
v += [0, 1.5, 2, 2.5][Math.min(ways, 3)]!;
if (loops > 0) v += 6 + Math.min(loops - 1, 1) * 2;
// Squares an industry could legally be laid on, joined to track a crew can reach.
const probe = protoCard({ kind: 'freightFacility', facility: 'mineTipple' }, 0)!;
const tried = new Set<string>();
let sites = 0;
for (const key of reachable) {
const [row, col] = key.split(',').map(Number);
for (const dc of [1, -1]) {
const at = { row: row!, col: col! + dc };
const k = coordKey(at);
if (tried.has(k) || area.grid.has(k) || at.row === area.runningRow) continue;
tried.add(k);
if (canPlaceAt(area, at, probe)) sites++;
}
}
v += [0, 2, 3, 3.5][Math.min(sites, 3)]!;
let mainStraight = false;
for (const [key, card] of area.grid) {
if (Number(key.split(',')[0]) !== area.runningRow || card.geometry.kind !== 'track') continue;
if (card.geometry.geometry === 'straight') mainStraight = true;
// A turnout on the main whose leg joins nothing is a hole in the Running Track with no road behind it.
if (card.geometry.geometry === 'turnout') {
const col = Number(key.split(',')[1]);
for (const side of SIDES) {
if (!hasPort(card, legPort(side))) continue;
const beyond = area.grid.get(`${area.runningRow + side},${col}`);
if (!beyond || !joins(card, legPort(side), beyond)) v -= 0.5;
}
}
}
if (mainStraight) v += 1.5;
return v;
}
/**
* The track lay worth most by `layoutValue`, placed on a copy of the district exactly as the reducer
* places it (`protoCard`, `extendLimitsIfNeeded`). With `mustBuild`, only a lay that gains something is
* offered, which is the slot `bestTrackLay` fills; without it, the best of whatever is legal, which is
* the slot the "play what is in hand" fallback fills. Ties go to the square nearer the Office.
*/
function bestValuedLay(s: GameState, player: PlayerIndex, options: Intent[], mustBuild: boolean): Intent | null {
const area = areaOf(s, player);
const base = layoutValue(area);
let best: Intent | null = null;
let bestScore = -Infinity;
for (const i of options) {
if (i.type !== 'card.play' || i.placement === undefined) continue;
const kind = s.cards.get(i.cardId)?.kind;
if (kind?.kind !== 'track') continue;
const built = protoCard(kind, i.variant);
if (!built) continue;
const after: OfficeArea = {
...area,
grid: new Map(area.grid),
limitsWest: { ...area.limitsWest },
limitsEast: { ...area.limitsEast },
};
after.grid.set(coordKey(i.placement), built);
extendLimitsIfNeeded(after, i.placement);
const gain = layoutValue(after) - base;
if (mustBuild && gain <= 0.1) continue;
const distance = Math.abs(i.placement.row - area.officeCoord.row) * 2 + Math.abs(i.placement.col - area.officeCoord.col);
const score = gain - distance * 0.01;
if (score > bestScore) {
bestScore = score;
best = i;
}
}
return best;
}
function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null {
const area = areaOf(s, player);
@@ -1213,7 +1425,7 @@ function followThrough(
if (!turnOf(s, player).drawnThisTurn) {
const piles = options.filter(
(i): i is Extract<Intent, { type: 'draw.fromDepartment' }> =>
i.type === 'draw.fromDepartment' && isWorthTaking(s, player, i.slot),
i.type === 'draw.fromDepartment' && isWorthTaking(s, player, i.slot, tweaks),
);
// Best-ranked pile rather than the first that qualifies: an Office card and a train card
// both "qualify", and only one of them stops the collisions.
@@ -1221,7 +1433,7 @@ function followThrough(
// and a train card are face up together 1.6 decisions a game — but ranking them is what the
// ranking function is for, and a coin flip on the card that decides whether the district
// ever becomes a Passenger Facility is not worth keeping for its own sake.
const useful = piles.sort((a, b) => takingRank(s, player, b.slot) - takingRank(s, player, a.slot))[0];
const useful = piles.sort((a, b) => takingRank(s, player, b.slot, tweaks) - takingRank(s, player, a.slot, tweaks))[0];
if (useful) return because('a face-up card is worth more than a blind draw right now', useful);
const blind = options.find((i) => i.type === 'draw.fromHomeOffice');
if (blind) return because('no face-up card is worth taking — gamble on the deck', blind);
@@ -1282,7 +1494,7 @@ function followThrough(
// STRAIGHTS that Enhancements require, and no Freight Facility has anywhere to go until a
// district exists. Measured with track absent, the hand held a playable Enhancement on 4,778
// turns and could legally place one on 33.
const track = bestTrackLay(s, player, options);
const track = !tweaks.noValueLays ? bestValuedLay(s, player, options, true) : bestTrackLay(s, player, options);
if (track) return because('lay track — nothing else creates the straights Enhancements need or the spurs freight needs', track);
// Then real development: a card actually laid into the grid. Freight facilities are scored —
@@ -1307,17 +1519,27 @@ function followThrough(
s.cards.get(i.cardId)?.kind.kind !== 'track',
);
if (placed) return because('develop the district with a card that goes on the board', placed);
const play = options.find((i) => i.type === 'card.play');
const play = !tweaks.noValueLays
? options.find((i) => i.type === 'card.play' && s.cards.get(i.cardId)?.kind.kind !== 'track') ??
bestValuedLay(s, player, options, false)
: options.find((i) => i.type === 'card.play');
if (play) return because('play what is in hand', play);
const end = options.find((i) => i.type === 'draw.end');
if (end) return because('nothing in hand can be played anywhere legal', end);
return because(
'nothing playable — discard onto the Department whose face-up card is least worth keeping reachable',
bestDiscard(s, player, options) ?? pickFirst(options, 'card.discard') ?? options[0]!,
bestDiscard(s, player, options, tweaks) ?? pickFirst(options, 'card.discard') ?? options[0]!,
);
}
case 'switch': {
// The planner decides the whole turn; the rules below are its fallback if the position is ever
// not the one it planned for, and the whole of switching under `noPlanSwitching`.
if (!tweaks.noPlanSwitching) {
const planned = plannedSwitch(s, player, options, tweaks);
if (planned) return planned;
}
/**
* A MOVE THAT DRAGS THE CREW'S OWN CUT BACK ON IS A WASTED MOVE, so take those off the table
* before any heuristic gets to choose one.
+2 -1
View File
@@ -23,6 +23,7 @@
* Run with:
* node src/sim/compare.ts 1600 noTrainCap=1 — what the A/D cap is worth today
* node src/sim/compare.ts 1600 noOperateFirst=1 — what operating before drawing is worth
* node src/sim/compare.ts 1600 noPlanSwitching=1 — what planning the switching turn is worth
*
* The flags are ABLATIONS: they turn off heuristics the bot already plays, so a negative delta is
* the heuristic earning its place. That is what a measured bot needs going forward — the question
@@ -221,7 +222,7 @@ export function formatPaired(r: PairedResult): string {
* against itself and report a confident zero, which is the most expensive way this tool could fail.
*/
export const NUMERIC_TWEAKS = new Set<string>([]);
export const BOOLEAN_TWEAKS = new Set(['noTrainCap', 'noOperateFirst']);
export const BOOLEAN_TWEAKS = new Set(['noTrainCap', 'noOperateFirst', 'noPlanSwitching', 'noPlayableTakes', 'noValueLays', 'noDeliberateNewTrain']);
export function parseTweaks(args: string[]): BotTweaks {
const tweaks: Record<string, number | boolean> = {};
+213 -30
View File
@@ -15,11 +15,13 @@
* panel cannot drift from the rules.
*/
import { MAX_CONSIST, crewTrayCount } from '../engine/content.ts';
import { MAINLINE_PROFILES, MAX_CONSIST, crewTrayCount } from '../engine/content.ts';
import { adTrackCount, coordKey, seatOf, turnOf } from '../engine/state.ts';
import 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.
@@ -134,7 +176,7 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
case 'actorChanged':
return {
tone: 'quiet',
text: e.player === null ? 'No player acts — automatic phase' : `Player ${e.player} to act`,
text: e.player === null ? 'No player acts — automatic phase' : 'to act',
};
// -- local operations
@@ -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
@@ -211,13 +294,19 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
? `Played ${card(e.cardId)} onto ${at(e.placement)}`
: `Played ${card(e.cardId)}`,
};
case 'mainlineModified':
case 'mainlineModified': {
// WHICH CARD, NOT JUST WHICH WAY IT WENT. "Mainline card 3 converted to plains" left the reader
// to remember what card 3 had been (playtest, 2026-09-15), and the card it WAS is the half that
// says what the play was worth.
const kindName = (k: string | undefined): string =>
MAINLINE_PROFILES.find((m) => m.kind === k)?.name ?? k ?? 'that card';
return {
tone: 'plain',
text: e.became
? `Realignment: Mainline card ${e.node} converted to ${e.became}`
: `Played ${e.key} on Mainline card ${e.node}`,
? `Realignment: Mainline card ${e.node}, ${kindName(e.from)}, converted to ${kindName(e.became)}`
: `Played ${e.key} on Mainline card ${e.node}${e.from ? ` (${kindName(e.from)})` : ''}`,
};
}
case 'redFlagSpent':
return {
tone: 'good',
@@ -225,8 +314,8 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
};
case 'redFlagRuled':
return e.flag
? { tone: 'plain', text: `Player ${e.player} flagged the approaching train` }
: { tone: 'plain', text: `Player ${e.player} waved the train through` };
? { tone: 'plain', text: 'Flagged the approaching train' }
: { tone: 'plain', text: 'Waved the train through' };
case 'redFlagsSet':
return {
tone: 'good',
@@ -337,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',
@@ -396,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',
@@ -416,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.`,
};
}
@@ -496,13 +647,13 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
? { tone: 'bad', text: `${e.reason.toUpperCase()} · ${e.delta} Revenue (now ${e.total})` }
: { tone: 'good', text: `+${e.delta} Revenue (now ${e.total}) — ${e.reason}` };
case 'phaseEnded':
return { tone: 'quiet', text: `Player ${e.player} finished ${phaseLabel(e.phase)}` };
return { tone: 'quiet', text: `Finished ${phaseLabel(e.phase)}` };
// -- §3.3, extended play (Gitea#11)
case 'extensionVoted':
return e.agree
? { tone: 'plain', text: `Player ${e.player} would play one more Day` }
: { tone: 'plain', text: `Player ${e.player} called time — the game ends here` };
? { tone: 'plain', text: 'Would play one more Day' }
: { tone: 'plain', text: 'Called time — the game ends here' };
case 'dayExtended':
return { tone: 'clock', text: `── The table plays on: Day ${e.day} is added to the timetable ──` };
case 'playConcluded':
@@ -511,8 +662,8 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
// -- §11, the Yard Office (Gitea#5)
case 'yardOfficeRuled':
return e.take
? { tone: 'plain', text: `Player ${e.player} sent ${train(e.trainId)} into the Yard Office` }
: { tone: 'plain', text: `Player ${e.player} kept ${train(e.trainId)} at the Train Order Office` };
? { tone: 'plain', text: `Sent ${train(e.trainId)} into the Yard Office` }
: { tone: 'plain', text: `Kept ${train(e.trainId)} at the Train Order Office` };
}
}
@@ -641,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.
+33 -6
View File
@@ -54,7 +54,7 @@ export const DWELL: Record<StepKind, number> = {
*
* WAS 250ms, WHICH WAS WRONG, and wrong in the way that mattered most: an early-game bot turn has
* no switching in it at all, so it was six steps of 250ms and 0ms — **750ms for a whole turn**.
* Jesse, from the first real play on `phoenix.local`: *"bot play was way too fast. I briefly saw
* Jesse, from the first real play on the test server: *"bot play was way too fast. I briefly saw
* that it was the bot's office area then their turn was done."* His instruction had been "start at
* 1s and tune down", and that was applied only to switching while this number was invented.
*/
@@ -143,10 +143,15 @@ export function kindOf(cause: StepCause): StepKind {
*
* `pace` has no lower surprise — 0 means off — but an unbounded upper one does: `?pace=300` from
* somebody typing 3.00, or a corrupt `localStorage` value, would give a switching move a five-minute
* dwell and look exactly like a frozen board. Ten is far beyond any speed anyone would choose (2 and
* 3 are the ones actually asked for) and well short of unusable.
* dwell and look exactly like a frozen board. Twenty is far past any speed anyone would choose and
* well short of unusable.
*
* RAISED FROM TEN 2026-09-10, because the ceiling turned out not to be theoretical: Jesse played at
* 10× — the top of the ladder — and reported it *"still a bit fast, but followable"*. A control whose
* slowest setting is not slow enough for the person using it has the wrong ceiling, not the right one
* held firmly.
*/
export const MAX_PACE = 10;
export const MAX_PACE = 20;
/**
* The speeds the on-screen control offers, slowest last.
@@ -158,7 +163,7 @@ export const MAX_PACE = 10;
* was real. Watching a bot shunt cars is the point of this feature, and it is worth as long as it
* takes.
*/
export const PACE_LEVELS = [0, 0.5, 1, 2, 3, 5, 7, 10] as const;
export const PACE_LEVELS = [0, 0.5, 1, 2, 3, 5, 7, 10, 15, 20] as const;
/**
* How long to show one step, in ms, at a given speed.
@@ -172,6 +177,28 @@ export function dwellFor(cause: StepCause, pace = 1): number {
return Math.round(DWELL[kindOf(cause)] * Math.min(MAX_PACE, Math.max(0, pace)));
}
/**
* How much of the speed control a PHASE gets — damped, not the full multiplier.
*
* Phases were pinned at their tabled beat in v0.8.0.3, because scaling them with everything else put
* a wall of clock-ticking after a player's own move. That was right about the cost and wrong about
* the need: at 10× the caption row goes past faster than the sentence on it can be read. Jesse,
* 2026-09-10: *"phases displayed on the upper line go by too quickly still. Should be 4 times as
* long — at a guess. Maybe use the speed multiplier for that too?"*
*
* So they scale, at a third of the rate. That lands exactly on his guess — 10× gives a phase four
* times its tabled beat — while leaving 1× untouched, and it stays affordable because phase beats
* cluster rather than accumulate: measured over 60 pushes, a push carries **1.0 phase beat on
* average and 4 at worst**, so the wait after a move goes to ~2.4s typical and ~10s at its very
* worst rather than the minutes a full multiplier would have cost.
*
* Below 1× it simply follows the multiplier: somebody asking for everything faster means the phases
* too.
*/
function phaseSpeed(pace: number): number {
return pace <= 1 ? pace : 1 + (pace - 1) / 3;
}
/**
* How long to show one STEP — the form the queue actually uses.
*
@@ -199,7 +226,7 @@ export function dwellForStep(
* The phase still gets its beat (TODO #18) — it just does not get longer because somebody wanted
* to watch a bot shunt cars.
*/
const speed = step.player === null ? 1 : pace;
const speed = step.player === null ? phaseSpeed(pace) : pace;
if (step.lines.length > 0) return dwellFor(step.cause, speed);
/**
* A SILENT STEP EARNS A BEAT ONLY WHEN THE CLOCK TURNED OVER — which is TODO #18 exactly: "give
+62
View File
@@ -130,3 +130,65 @@ function need<T>(value: T | undefined, what: string): T {
}
return value;
}
/** A face-up or face-down pile a card can move to or from, as the display addresses it. */
export type PileKey = 'home' | 'salvage' | `dept${number}`;
/**
* WHICH PILES A STEP MOVED — derived, never sent.
*
* The receiver already holds the frame before a step and the frame after it, so which pile changed
* is a diff rather than something the wire has to carry. That matters twice over: nothing is added
* to the protocol, and it cannot drift out of step with the projection the way a hand-maintained
* hint would.
*
* WHY IT IS NEEDED AT ALL. A player watching somebody else draw a card sees seven seconds of an
* unchanged board — the step holds the screen, and the only thing that moved is a number in a panel
* they were not looking at. Jesse, playing v0.8.0.4 at 10×: *"many operations still occurred too fast
* for me to see"*, which was never about duration. Lighting the pile is what tells the eye where.
*
* WHAT EACH ACTION MOVES, measured across four seeds rather than reasoned about:
*
* | intent | piles |
* | ----------------------- | -------------------------------------------------------- |
* | `draw.fromHomeOffice` | `home` — the COUNT only; the card itself stays private |
* | `draw.fromDepartment` | that `dept`, and `home` too when the pile refills from it |
* | `card.discard` | that `dept` |
* | `card.play` | `salvage`, or nothing here when it lands on the board |
* | switching, new trains | nothing here — those show on the board itself |
*/
/**
* Mainline cards that became a different card between two public boards — a Realignment, which is the
* one play that changes the Division itself.
*
* Playtest, 2026-09-15: *"is it possible to flash the mainline card when it gets changed by realignment?
* This would be more obvious to see what's happening on the map."* Detected the same way `changedPiles`
* detects a pile moving — by comparing the two boards the queue already holds — rather than by reading
* the event, so the flash lands with the step that shows it and not when the intent arrived.
*/
export function changedDivisionCards(before: PublicFrame | null, after: PublicFrame): number[] {
if (before === null) return [];
const out: number[] = [];
after.division.forEach((node, i) => {
const was = before.division[i];
if (was && was.kind === 'ml' && node.kind === 'ml' && was.label !== node.label) out.push(i);
});
return out;
}
export function changedPiles(before: PublicFrame | null, after: PublicFrame): PileKey[] {
if (before === null) return [];
const out: PileKey[] = [];
if (before.deck !== after.deck) out.push('home');
after.departmentDepth.forEach((depth, i) => {
// The TOP as well as the depth: taking the face-up card and replacing it leaves the count alone
// and changes the card everybody can see, which is the half that matters to a watcher.
if (before.departmentDepth[i] !== depth || before.departments[i] !== after.departments[i]) {
out.push(`dept${i}`);
}
});
if (before.salvage.depth !== after.salvage.depth || before.salvage.top !== after.salvage.top) {
out.push('salvage');
}
return out;
}
+2
View File
@@ -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.
+347
View File
@@ -0,0 +1,347 @@
/**
* Component 17b — planning a whole switching turn before making the first Move.
*
* Dev-side, like the rest of the bot. The developer bot's switching branch chooses ONE move at a time
* from a ladder of rules, and its own comment names what that cannot do: "a strong player would use
* the six Moves to re-order the consist — that is the game's central switching puzzle, and this bot
* does not attempt it." This attempts it, for one turn at a time.
*
* WHY SEARCH IS FAIR HERE. A switching turn draws no card and rolls no die, so trying sequences on a
* copy of the game is exactly what a player does by looking at the board. The score below reads only
* what a player can see — the district, the cars on the trains, the facilities — and never the deck.
*
* WHY NOT EVERY SEQUENCE. Measured 2026-09-14 over 30 switching turns from bot games: a median turn
* reaches 229 distinct positions, but 11 of 30 passed 20,000, because setting cars out is free and a
* crew can leave them in a great many places. So the search keeps the best `beam` positions at each
* step and stops at `budget` positions tried. Small turns are searched completely inside that.
*
* THE SCORE IS OF WHERE THE TURN ENDS, not of what it did, and it starts from Jesse's ruling
* (2026-09-14): "players will attempt to deliver / pick up cars even if it delays trains." So a car
* put where it can be worked is worth a point, and a train left away from the Office costs a quarter
* of one. The weights are a starting point to measure, not a result.
*/
import { areaAtSeat, areaOf, commitEvents, facilityCarTypes, prepareIntent, withRouteCache } from '../engine/apply.ts';
import { badlyMadeUp, isExpedited } from '../engine/advance.ts';
import { MAX_CONSIST } from '../engine/content.ts';
import type { Intent } from '../engine/intents.ts';
import { legalSwitchingActions } from '../engine/legal.ts';
import { cloneTally, coordKey, seatOf, turnOf } from '../engine/state.ts';
import type { Facility, GameState, GridCoord, PlayerIndex, RollingStock, TrackCard } from '../engine/state.ts';
export const SWITCH_WEIGHTS = {
/** A car standing where its industry can load or unload it — the point of switching. */
spot: 1.0,
/** The same, past what the industry's boxes can work at once. */
spotBeyondCapacity: 0.25,
/** A car the industry cannot work, taking room on its track. */
junkOnIndustry: -0.5,
/** A finished car — loaded at a shipper, emptied at a receiver — still waiting to be lifted. */
finishedLeft: -0.15,
/** A car on one of this district's trains that some industry here would work. */
carriedWanted: 0.35,
/** The same car left on ordinary track, where a later turn can fetch it. */
stagedWanted: 0.2,
/** A coach kept with its train, or parked at the Office where §A.4 allows it. */
coachWithTrain: 0.3,
/** A coach left anywhere else, where no Porter can work it. */
coachStranded: -0.3,
/** Anything but a coach standing on the Office square — the next arrival collides (§8.3). */
fouling: -3,
/** A train that ends the turn away from the Office and so cannot highball next Mainline Phase. */
trainAway: -0.25,
/** On top of that, an expedited train — Q3 charges a Revenue point every Phase it is away. */
expeditedAway: -1.0,
/** A train that could not leave even from the Office — engine buried, caboose mid-train (§8.2). */
notMadeUp: -0.6,
/** Tie-breaks, so equal outcomes prefer the plan that does less. */
perMove: -0.02,
perSetOut: -0.005,
/** A maneuver card spent — Flying Switch — so the planner plays one only when it buys something. */
cardSpent: -0.1,
} as const;
export type PlanOptions = {
budget: number;
beam: number;
/**
* Search Flying Switch alongside Moves, set-outs and sorts. On by default but UNMEASURED: the card
* is dealt 0 copies (Jesse, 2026-08-26), so over 400 paired seeds turning it on changed nothing —
* it is here so the planner can use the card the day it is dealt again.
*/
flyingSwitch?: boolean;
};
/**
* Measured 2026-09-14, paired over 400 seeds against 3000/48: 2000/32 cost −0.02 ± 0.01 (t = −1.68,
* inside the noise) at half the time per turn; 1000/24 cost −0.06 ± 0.02 (t = −2.65) for little more.
*/
export const DEFAULT_PLAN: PlanOptions = { budget: 2000, beam: 32, flyingSwitch: true };
export type SwitchPlan = {
/** The intents to submit, in order. Empty when nothing beats stopping where the crew stands. */
steps: Intent[];
/** `switchFingerprint` before each step, and after the last — so a caller can tell it is on plan. */
keys: string[];
rootScore: number;
score: number;
/** Positions tried. */
expanded: number;
/** False when the budget ran out before the search did. */
complete: boolean;
};
/**
* A copy of the game that a switching intent can be applied to without touching the original.
*
* NOT `structuredClone`, of the state or even of the district. A switching intent writes only the
* cars standing on cards, the industry tracks, the district's A/D and held lists, the consist and
* position of the trays standing in it, this player's turn and the tally — so exactly those arrays are
* copied and everything else is shared by reference. Measured 2026-09-14, deep-cloning the district
* was 44% of all planning time.
*
* `test/switch-planner.test.ts` proves across real games that planning leaves the original
* byte-identical — which is what fails first if a reducer ever starts writing somewhere new, or
* starts mutating a car or a card in place instead of replacing it.
*/
export function forkForSwitching(s: GameState, player: PlayerIndex): GameState {
const seat = seatOf(s, player);
const area = areaAtSeat(s, seat);
const grid = new Map<string, TrackCard>();
for (const [key, card] of area.grid) {
const f = card.facility;
grid.set(key, {
...card,
standing: [...card.standing],
facility: f ? { ...f, industryTrack: { cars: [...f.industryTrack.cars] } } : f,
});
}
const officeAreas = new Map(s.officeAreas);
officeAreas.set(seat, {
...area,
grid,
adOccupancy: [...area.adOccupancy],
heldAtLimits: [...area.heldAtLimits],
dispatchUsedToday: [...area.dispatchUsedToday],
});
const trays = new Map(s.trays);
for (const [id, t] of s.trays) {
if (t.position.at === 'grid' && t.position.seat === seat) trays.set(id, { ...t, consist: [...t.consist] });
}
const turns = new Map(s.turns);
const turn = s.turns.get(player)!;
turns.set(player, { ...turn, freightWorked: { ...turn.freightWorked } });
// Flying Switch spends its card (`spendCard`): the hand map is rewritten and the Salvage Yard grows.
const decks = { ...s.decks, hands: new Map(s.decks.hands), salvageYard: [...s.decks.salvageYard] };
return { ...s, officeAreas, trays, turns, decks, tally: cloneTally(s.tally) };
}
const carList = (xs: readonly RollingStock[]): string =>
xs.map((c) => `${c.type}${c.loaded ? '+' : '-'}${c.origin ?? ''}`).join(',');
/**
* Everything a switching intent can change, as a string — two positions with the same fingerprint
* are the same position as far as the rest of the turn is concerned. Identical cars are not told
* apart, which is right: no intent names a car.
*/
export function switchFingerprint(s: GameState, player: PlayerIndex): string {
const seat = seatOf(s, player);
const parts: string[] = [];
for (const [id, t] of s.trays) {
if (t.position.at !== 'grid' || t.position.seat !== seat) continue;
const { row, col } = t.position.coord;
parts.push(`${id}@${row},${col}/${t.facing}/${t.railFacing ?? ''}/${t.engineAt}:${carList(t.consist)}`);
}
for (const [key, card] of areaOf(s, player).grid) {
const track = card.facility?.kind === 'freight' ? card.facility.industryTrack.cars : null;
if (card.standing.length === 0 && card.standingWest === 0 && (track?.length ?? 0) === 0) continue;
parts.push(`${key}=${carList(card.standing)}|${card.standingWest}|${track ? carList(track) : ''}`);
}
const turn = turnOf(s, player);
parts.push(`m${turn.movesRemaining}`, JSON.stringify(turn.freightWorked), `h${(s.decks.hands.get(player) ?? []).join(',')}`);
return parts.join(';');
}
/**
* §9.3 — an outbound industry loads EMPTY cars of its commodity, an inbound one unloads LOADED ones —
* but never a load that was made in this same district (v0.4.9e, `LOADED_IN_THIS_DISTRICT`).
*/
function works(f: Facility, c: RollingStock, seat: number): boolean {
if (f.kind !== 'freight' || !facilityCarTypes(f).includes(c.type)) return false;
return (!c.loaded && f.allows.outbound) || (c.loaded && f.allows.inbound && c.origin !== seat);
}
/**
* The car an industry has finished with. Only decidable at a one-way industry: at one that both
* ships and receives, a loaded car may be a delivery still waiting to be unloaded.
*/
function finished(f: Facility, c: RollingStock): boolean {
if (f.kind !== 'freight' || !facilityCarTypes(f).includes(c.type)) return false;
if (f.allows.outbound && !f.allows.inbound) return c.loaded;
if (f.allows.inbound && !f.allows.outbound) return !c.loaded;
return false;
}
const same = (a: GridCoord, b: GridCoord): boolean => a.row === b.row && a.col === b.col;
/** How good this district's position is for the rest of the game, in rough Revenue points. */
export function evaluateSwitching(s: GameState, player: PlayerIndex): number {
const W = SWITCH_WEIGHTS;
const area = areaOf(s, player);
const seat = seatOf(s, player);
const officeKey = coordKey(area.officeCoord);
const passengerOffice = area.grid.get(officeKey)?.facility?.kind === 'passenger';
let v = 0;
const withRoom: Facility[] = [];
for (const card of area.grid.values()) {
const f = card.facility;
if (!f || f.kind !== 'freight') continue;
if (f.industryTrack.cars.length < MAX_CONSIST) withRoom.push(f);
const cap = Math.max(1, f.capacity.outbound + f.capacity.inbound);
let working = 0;
for (const c of f.industryTrack.cars) {
if (works(f, c, seat)) v += ++working <= cap ? W.spot : W.spotBeyondCapacity;
else if (finished(f, c)) v += W.finishedLeft;
else v += W.junkOnIndustry;
}
}
const wanted = (c: RollingStock): boolean => withRoom.some((f) => works(f, c, seat));
for (const [key, card] of area.grid) {
if (card.facility?.kind === 'freight') continue;
const atOffice = key === officeKey;
for (const c of card.standing) {
if (c.type === 'coach') v += atOffice && passengerOffice ? W.coachWithTrain : W.coachStranded;
else if (atOffice) v += W.fouling;
else if (wanted(c)) v += W.stagedWanted;
}
}
for (const t of s.trays.values()) {
if (t.position.at !== 'grid' || t.position.seat !== seat) continue;
for (const c of t.consist) {
if (c.type === 'coach') v += passengerOffice ? W.coachWithTrain : 0;
else if (wanted(c)) v += W.carriedWanted;
}
if (t.trainNumber === null) continue;
if (!same(t.position.coord, area.officeCoord)) {
v += W.trainAway;
if (isExpedited(t)) v += W.expeditedAway;
}
if (badlyMadeUp(t)) v += W.notMadeUp;
}
return v;
}
/**
* For ORDERING the beam only, never for choosing the plan: a Move toward an industry changes nothing
* the score can see until the car is set out, so without this the beam would drop the approach in
* favour of positions that merely look tidy.
*/
function approach(s: GameState, player: PlayerIndex): number {
const area = areaOf(s, player);
const seat = seatOf(s, player);
const targets: { at: GridCoord; f: Facility }[] = [];
for (const [key, card] of area.grid) {
const f = card.facility;
if (!f || f.kind !== 'freight' || f.industryTrack.cars.length >= MAX_CONSIST) continue;
const [row, col] = key.split(',').map(Number);
targets.push({ at: { row: row!, col: col! }, f });
}
let bonus = 0;
for (const t of s.trays.values()) {
if (t.position.at !== 'grid' || t.position.seat !== seat) continue;
const here = t.position.coord;
for (const c of t.consist) {
let nearest = Infinity;
for (const { at, f } of targets) {
if (works(f, c, seat)) nearest = Math.min(nearest, Math.abs(at.row - here.row) + Math.abs(at.col - here.col));
}
if (nearest !== Infinity) bonus += 0.1 / (1 + nearest);
}
}
return bonus;
}
const SEARCHED = new Set<Intent['type']>(['switch.move', 'switch.dropCars', 'switch.sortConsist']);
type Node = {
s: GameState;
steps: Intent[];
keys: string[];
moves: number;
setOuts: number;
cards: number;
score: number;
rank: number;
};
/** The best way found to spend what is left of this switching turn. Never mutates `s`. */
export function planSwitchingTurn(
s: GameState,
player: PlayerIndex,
opts: PlanOptions = DEFAULT_PLAN,
): SwitchPlan {
const W = SWITCH_WEIGHTS;
const scoreOf = (st: GameState, moves: number, setOuts: number, cards: number): number =>
evaluateSwitching(st, player) + moves * W.perMove + setOuts * W.perSetOut + cards * W.cardSpent;
const searched = (type: Intent['type']): boolean =>
SEARCHED.has(type) || (opts.flyingSwitch === true && type === 'maneuver.flyingSwitch');
const rootKey = switchFingerprint(s, player);
const rootScore = scoreOf(s, 0, 0, 0);
const root: Node = { s, steps: [], keys: [rootKey], moves: 0, setOuts: 0, cards: 0, score: rootScore, rank: rootScore };
let best = root;
const seen = new Set([rootKey]);
let frontier: Node[] = [root];
let expanded = 0;
let complete = true;
search: while (frontier.length > 0) {
const next: Node[] = [];
for (const node of frontier) {
const movesLeft = turnOf(node.s, player).movesRemaining;
// Every candidate is decided against THIS position, inside one route cache, and only then applied
// to its own copy: deciding on the copy would re-walk routes the listing had just walked.
const decided = withRouteCache(node.s, () =>
legalSwitchingActions(node.s, player)
.filter((i) => searched(i.type) && (i.type === 'switch.dropCars' || movesLeft >= 1))
.map((i) => ({ i, r: prepareIntent(node.s, player, i) })),
);
for (const { i, r } of decided) {
if (expanded >= opts.budget) {
complete = false;
break search;
}
expanded++;
if (!r.ok) continue;
const f = forkForSwitching(node.s, player);
commitEvents(f, r.events);
const key = switchFingerprint(f, player);
if (seen.has(key)) continue;
seen.add(key);
const setOut = i.type === 'switch.dropCars';
const moves = node.moves + (setOut ? 0 : 1);
const setOuts = node.setOuts + (setOut ? 1 : 0);
const cards = node.cards + (i.type === 'maneuver.flyingSwitch' ? 1 : 0);
const score = scoreOf(f, moves, setOuts, cards);
const child: Node = {
s: f,
steps: [...node.steps, i],
keys: [...node.keys, key],
moves,
setOuts,
cards,
score,
rank: score + approach(f, player),
};
if (score > best.score + 1e-9) best = child;
next.push(child);
}
}
// A stable sort, so equal ranks keep `legalActions` order and the bot stays deterministic.
frontier = next.length > opts.beam ? next.sort((a, b) => b.rank - a.rank).slice(0, opts.beam) : next;
}
return { steps: best.steps, keys: best.keys, rootScore, score: best.score, expanded, complete };
}
+21 -3
View File
@@ -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
View File
@@ -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 } : {}),
};
}
+157 -3
View File
@@ -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).
@@ -1255,9 +1382,24 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
const said = blindDraw ? 'Drew a card from the Home Office deck' : n.text;
// "Chose to DRAW a card" does not say WHO, which is unreadable the moment there is more than
// one seat. Only events the player caused are attributed; the Division running itself is not.
/**
* A RULING IS MADE AS SUPERINTENDENT, NOT AS YOURSELF (playtest, 2026-09-15: "maybe it could say
* 'Superintendent Player Tom', so it's clear they got the move because they're Superintendent").
* These three are the only moves a player makes out of turn, by holding the office: §8.1's
* clearance, §11's Yard Office offer and §Q's Red Flag prompt. `clearanceGiven` carries no
* player at all — the office made it, whoever holds it — so the actor is what names it.
*/
const RULINGS = ['clearanceGiven', 'yardOfficeRuled', 'redFlagRuled'];
const ruling = RULINGS.includes(e.type) && who !== null;
const mine = who !== null && 'player' in e;
const text = mine ? `Player ${who} ${uncapitalise(said)}` : said;
game.log.push({ text, tone: mine ? 'act' : n.tone });
const text = ruling
? `Superintendent Player ${who} ${uncapitalise(said)}`
: mine
? `Player ${who} ${uncapitalise(said)}`
: said;
// `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));
@@ -1274,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
View File
@@ -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' },
+464 -36
View File
@@ -25,9 +25,9 @@ import type { LocalSession, Session } from './session.ts';
import { createLocalSession, createRemoteSession } from './session.ts';
import type { PlayerIndex } from '../engine/state.ts';
import type { PublicDistrict } from '../sim/view.ts';
import { createStepQueue } from './step-queue.ts';
import { 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,
@@ -184,6 +184,20 @@ function drainIntoQueue(): void {
const reset = session.takeDisplayReset();
if (reset) stepQueue.reset(reset);
stepQueue.push(session.takeDisplaySteps());
/**
* NOWHERE TO ANIMATE MEANS DO NOT QUEUE AT ALL.
*
* Without `requestAnimationFrame` nothing ever advances the queue, so `busy()` would stay true for
* good — and since "Your Move" is now put away while the board is catching up, that would hide a
* player's own actions permanently, leaving Skip as the only way to play the game. Drawing
* everything at once is exactly what `pace = 0` does deliberately, so that is the honest fallback
* rather than a broken page. Caught by `test/web.test.ts`, whose DOM stub has no `rAF` — the same
* stub that has been proving this page still starts since long before any of this existed.
*/
if (typeof requestAnimationFrame !== 'function') {
stepQueue.skip();
return;
}
if (stepQueue.busy()) startAnimationLoop();
}
@@ -217,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.
*
@@ -259,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 {
@@ -271,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;
@@ -316,12 +394,25 @@ function startAnimationLoop(): void {
*/
console.error('display queue stopped:', err);
animating = false;
// Do not strand the player behind a queue that can no longer advance: jump the board to the
// live position, which brings "Your Move" back with it.
try {
stepQueue.skip();
} catch {
/* nothing further to try — the authoritative Frame is still what the rest of the page draws */
}
render();
return;
}
if (!stepQueue.busy()) {
animating = false;
// One last render so the "N behind" row collapses the moment the board is level.
renderWatching();
/**
* A FULL RENDER, not just the row. The board being level again is what brings "Your Move"
* back and clears the last lit pile, so redrawing only the catching-up row would leave the
* action list hidden until something else happened to trigger a render — which, when the game
* is waiting on this player, is nothing at all.
*/
render();
return;
}
requestAnimationFrame(tick);
@@ -403,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. */
@@ -466,12 +573,20 @@ function piecePreview(links: string[], label: string): string {
* are in the Day the same way and with the same violet highlight. It used to live here alone.
*/
function renderTurnChart(f: Frame): void {
const actorName = f.actor === null ? null : (f.players[f.actor]?.name ?? null);
// The move on screen, not the live one, while the board is still catching up (Gitea#25).
const { actor, replaying } = actorOnScreen(stepQueue, f.actor);
const actorName = actor === null ? null : (f.players[actor]?.name ?? null);
// The Day, Stage and phase of the step being shown, so the whole screen reports one moment.
const table = shownTable(f);
// Named only at a table with more than one seat: in solitaire the Fedora is always yours, and a
// 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(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,
);
}
/**
@@ -973,6 +1088,7 @@ function beginRemote(ready: LobbyReady, rejoining = false): void {
// banner (`#presence`), and it holds a beat so the game visibly begins.
openHandoff();
session = createRemoteSession(ready.token, ready.seat, abandonRemote);
remoteToken = ready.token;
rejoiningRemote = rejoining;
applyCapabilities();
// A LocalSession has data the instant it is constructed; a RemoteSession does not — its first
@@ -1018,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.
*
@@ -1140,6 +1313,14 @@ function start(): void {
* the lobby's job. Hidden rather than disabled: a greyed-out Undo in a multiplayer game invites the
* question "why not?" every turn, and the honest answer is that the control does not belong there.
*/
/**
* The session token of a server-backed game, or null in solitaire (playtest, 2026-09-15: "most of the
* time, I want to go ahead and just save it as a JSON file"). It is the seat's proof of identity to
* `/api/save`, exactly as it is to `/api/stream` — a save is the seed and the moves, every one of which
* is already on this player's screen.
*/
let remoteToken: string | null = null;
function applyCapabilities(): void {
const c = session.capabilities;
const hide = (id: string, on: boolean): void => {
@@ -1147,7 +1328,7 @@ function applyCapabilities(): void {
if (el) el.hidden = !on;
};
hide('undo', c.undo);
hide('savefile', c.saveLocal);
hide('savefile', c.saveLocal || remoteToken !== null);
hide('newgame', c.newGame);
// Creating or joining ANOTHER multiplayer game from inside a running one is not a thing this
// page offers — same reasoning as `newgame`, and the same capability answers both.
@@ -1320,8 +1501,10 @@ function render(): void {
// -- division
$('division').innerHTML = divisionSvg(f.division, {
players: f.players,
actor: f.actor,
actor: actorOnScreen(stepQueue, f.actor).actor,
viewer: f.viewer,
// A Realignment changes the Division under everyone; flashed only while the step that did it is up.
flash: stepQueue.busy() ? stepQueue.flashing() : [],
});
renderSeatingChain(f);
applyZoom($('division'));
@@ -1511,7 +1694,9 @@ function render(): void {
* reach. Drawn like the hand so they read as cards, dashed and unlit because taking one is a draw
* action rather than a click on the card itself.
*/
$('depts').innerHTML = pilesHtml(f);
// The pile the move being WATCHED just touched, lit for as long as that step is on screen. Empty
// whenever the board is level with the game, or when the move was this player's own.
$('depts').innerHTML = pilesHtml(f, stepQueue.busy() ? stepQueue.lit() : []);
renderYards(f);
@@ -1572,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(
@@ -1604,8 +1798,36 @@ function render(): void {
// -- log
const log = $('log');
const allLines = session.lines();
const shownLines = allLines.slice(-60);
/**
* THE LOG IS HELD BACK WITH THE BOARD (playtest, 2026-09-15).
*
* A push carries its narration and its display steps together, so every line of a bot's turn was in
* this panel before the board had drawn a single move of it — the history ran ahead of the "N behind"
* counter it is meant to match. Those lines are the TAIL of the log, so exactly the ones belonging to
* steps still queued are withheld, and each appears as its step goes up.
*/
const heldBack = stepQueue.pendingLines();
/**
* 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
@@ -1728,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` : '');
/**
@@ -1784,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;
}
/**
@@ -1985,6 +2306,29 @@ function renderActions(
renderEnding(el, f);
return;
}
/**
* YOUR MOVE IS PUT AWAY WHILE THE BOARD IS CATCHING UP — Jesse, 2026-09-10: *"your actions should
* be hidden while catching up."*
*
* Two reasons, and the second is the one that changed my mind about it. The board on screen is
* behind the game, so a move offered here is a move against a position that has already moved on —
* the menu is computed from the CURRENT state and would be acted on while looking at an older one.
* And the display had grown to four things demanding attention at once — the district, the history,
* the catching-up row and now a lit pile — which is what made the pile highlight so easy to miss.
* Taking the action list out of that competition while there is nothing to decide anyway is the
* cheapest way to quieten it.
*
* NOT A BLOCK. Skip is one click away and sits at the left of the row, so the wait is always
* voluntary; this replaces the buttons with the reason they are gone, rather than leaving a live
* menu over a stale board.
*/
if (stepQueue.busy()) {
el.innerHTML =
'<div class="dim">Catching up on what everyone else did — your move is here when the board is ' +
'level with the game. <b>Skip</b> jumps straight to it.</div>';
return;
}
// The game is running, so the next ending — an extended Day's, or a fresh game's — is entitled to
// put its results up unasked again (Gitea#11).
resultsShown = false;
@@ -2097,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
@@ -2129,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.') +
@@ -2265,21 +2654,60 @@ function renderActions(
* hundred bytes, so a finished game can be emailed or dropped on the site's replay directory —
* where a rendered page would have been megabytes.
*/
function downloadSave(): void {
// The button this fires from is hidden by `applyCapabilities()` for any session that cannot save
// (`#savefile`), but nothing stops this function being called directly, so the guard is repeated
// here rather than only trusted to the DOM.
if (!isLocal(session)) return;
const data = JSON.stringify(session.save(), null, 1);
function writeFile(name: string, data: string): void {
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `station-master-seed${session.seed()}-day${session.view().day}.json`;
a.download = name;
a.click();
URL.revokeObjectURL(url);
}
/**
* WHICH GAME, HOW FAR IN, AND WHEN — Jesse, playtest 2026-09-16.
*
* The name was `station-master-day1-stage5.json` for every server game at that point in every
* Stage, so two saves off the same table collided in the downloads folder and neither said which
* table it came from. The join code is the one thing a player already says out loud to identify a
* game, so it leads: `whistle-6945.day1.stage5.2026.09.16.json`.
*
* Lowercased because a filename is not a thing you shout, and dotted because that is the shape
* Jesse asked for. A solitaire game has no join code and falls back to its seed, which is the
* equivalent identity for a game nobody else is sitting at.
*/
function saveFileName(prefix: string, f: { day: number; stage: number }): string {
const d = new Date();
const pad = (n: number): string => String(n).padStart(2, '0');
const date = `${d.getFullYear()}.${pad(d.getMonth() + 1)}.${pad(d.getDate())}`;
return `${prefix}.day${f.day}.stage${f.stage}.${date}.json`;
}
async function downloadSave(): Promise<void> {
const f = session.view();
/**
* A SERVER-BACKED GAME HAS NO LOCAL SAVE TO HAND OVER, so it asks the server for its own — the seat's
* token is the gate (`/api/save`), the same one the stream and every intent already use. The StartOS
* Manage Game action cannot do this: an action result is text only, with no file member in the SDK.
*/
if (!isLocal(session)) {
if (remoteToken === null) return;
try {
const res = await fetch(`/api/save?token=${encodeURIComponent(remoteToken)}`);
if (!res.ok) return;
const body = (await res.json()) as { save: unknown };
const code = gameCode === '' ? 'station-master' : gameCode.toLowerCase();
writeFile(saveFileName(code, f), JSON.stringify(body.save, null, 1));
} catch {
// Offline, or the game has been ended under us: the button simply does nothing, which is the
// same thing every other server call on this page does when the server is not there.
}
return;
}
const solo = gameCode === '' ? `station-master-seed${session.seed()}` : gameCode.toLowerCase();
writeFile(saveFileName(solo, f), JSON.stringify(session.save(), null, 1));
}
function save(): void {
if (!isLocal(session)) return;
try {
@@ -2315,7 +2743,7 @@ document.head.appendChild(pageStyle);
installTooltips();
const saveBtn = document.getElementById('savefile');
if (saveBtn) saveBtn.onclick = downloadSave;
if (saveBtn) saveBtn.onclick = () => void downloadSave();
/**
* Forget the saved game and deal a fresh one.
+76 -6
View File
@@ -52,25 +52,61 @@ export function handHtml(f: Frame, canPlay: (boolean | null)[] = []): string {
* Only the top card may ever be drawn, so the depth is a count and not a hint: everything below it
* is out of reach, and choosing where to discard is choosing what to put there.
*/
export function pilesHtml(f: Frame): string {
const pile = (label: string, top: string, depth: number, why: string, extra = '', slot = -1): string => {
export function pilesHtml(f: Frame, lit: readonly string[] = []): string {
const pile = (
key: string,
label: string,
top: string,
depth: number,
why: string,
extra = '',
slot = -1,
faceDown = false,
): string => {
const tip = [why, extra].filter(Boolean).join(' · ');
// A Department is a DROP TARGET for a discard. The attribute is always emitted; only the play
// page binds a click to it, and only while a card is waiting to be discarded — so the replay
// viewer draws exactly the same markup and nothing there is clickable.
const target = slot >= 0 ? ` data-dept="${slot}"` : '';
// `lit` marks the pile the move being watched just touched — see `changedPiles`.
const cls = `handcard${faceDown ? ' facedown' : ''}${lit.includes(key) ? ' pilelit' : ''}`;
return (
`<div class="handcard"${target}${tip ? ` data-tip="${esc(tip)}"` : ''} tabindex="0">` +
`<div class="${cls}"${target}${tip ? ` data-tip="${esc(tip)}"` : ''} tabindex="0">` +
`<div class="pilehd"><span>${esc(label)}</span><span class="depth">${depth}</span></div>` +
`<b>${esc(top)}</b></div>`
);
};
return (
/**
* THE HOME OFFICE DECK, which the screen had never drawn.
*
* `f.deck` has carried the face-down count since the Frame existed and nothing read it — the
* exact shape of display gap `test/display-gaps.test.ts` was written to sweep for, surviving in
* the panel that draws every OTHER pile. Asked for by Jesse 2026-09-10 for a second reason: a
* player drawing from it is the commonest move nobody can see, so it needs somewhere to flash.
*
* FIRST, because that is the order a card travels: out of here, into a hand, then onto a
* Department or the Salvage Yard. Face down, so the card slot says so rather than naming a card
* — the whole point of this pile is that nobody knows what is on top.
*/
pile(
'home',
'Home Office',
'face down',
f.deck,
'The draw deck. Face down — nobody sees what is on top, and a card drawn from here is private ' +
'to whoever drew it. When it runs out, the Salvage Yard and the Departments are swept back ' +
'into it.',
'',
-1,
true,
) +
f.departments
.map((d, i) => {
const depth = f.departmentDepth[i] ?? 0;
const under = depth - 1;
return pile(
`dept${i}`,
`Dept ${i + 1}`,
d,
depth,
@@ -81,6 +117,7 @@ export function pilesHtml(f: Frame): string {
})
.join('') +
pile(
'salvage',
'Salvage',
f.salvage.top,
f.salvage.depth,
@@ -676,6 +713,30 @@ h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:#8b94a3;ma
.handcard:focus{outline:2px solid #4d6fa8;outline-offset:1px}
.cardrow.ref .handcard{background:#1c2129;border-style:dashed;border-color:#39424e;color:#b6bec9}
.handcard.unplayable{color:#7d8794;border-color:#39424e}
/* THE HOME OFFICE DECK. Face down, so its card slot names no card — it says so instead, in the
dimmed voice the rest of the panel uses for "nothing to read here". */
.handcard.facedown > b{color:#6f7885;font-style:italic;font-weight:400}
/* THE PILE A WATCHED MOVE JUST TOUCHED (v0.8.1).
A STATE, NOT A FLASH, and that is the whole point. The .tt-slot.fresh rule above animates for a fixed
1.5s, which is right for a die roll nobody is waiting on — but a step can hold the screen for
seven seconds at 10x, so a fixed animation would be over long before the pause it belongs to and
the player would be back to staring at an unchanged board. The flash-in marks the moment; the lit
border and background stay for exactly as long as the step is up, because the class is on the
element only while that step is the one being shown. */
.handcard.pilelit{border-color:#8fd6a0;background:#1d3327;box-shadow:0 0 0 2px #2f6b47,0 0 14px rgba(143,214,160,.55);
animation:pilepulse 1.15s ease-in-out infinite}
/* A PULSE FOR THE WHOLE DWELL, not one flash at the start. Measured: at 10x a pile stays lit for
just under seven seconds, so the highlight was never brief — but a single 0.45s flash-in and a
dark green fill were easy to miss entirely while watching the district. Jesse: "caught one flash
deck light up for just a very brief moment, but couldn't see that with what bot was doing in
office area and history and catch up area all at same time." Something still moving keeps drawing
the eye for as long as the move is up; a state that settles stops asking to be looked at. */
@keyframes pilepulse{0%,100%{background:#1d3327;box-shadow:0 0 0 2px #2f6b47,0 0 14px rgba(143,214,160,.45)}
50%{background:#2f6b47;box-shadow:0 0 0 3px #8fd6a0,0 0 22px rgba(143,214,160,.85)}}
/* Motion is the point here, so the reduced-motion fallback has to be loud in a different way rather
than simply not moving: a solid ring and a brighter fill, held. */
@media(prefers-reduced-motion:reduce){
.handcard.pilelit{animation:none;background:#2f6b47;box-shadow:0 0 0 3px #8fd6a0}}
.handcard.unplayable::after{content:"";position:absolute;inset:0;border-radius:5px;pointer-events:none;
background:repeating-linear-gradient(45deg,transparent 0 5px,rgba(150,160,175,.20) 5px 6px)}
/* THE CARD JUST DRAWN. It sits first in the row, and this says which one that is — three cards that
@@ -716,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
View File
@@ -124,7 +124,7 @@ section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
#watching-what{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.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
View File
@@ -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')
+109 -1
View File
@@ -19,7 +19,8 @@
import type { PublicFrame } from '../sim/view.ts';
import type { DisplayStep } from '../sim/display-step.ts';
import { applyPublicDelta } from '../sim/public-delta.ts';
import { applyPublicDelta, changedDivisionCards, changedPiles } from '../sim/public-delta.ts';
import type { PileKey } from '../sim/public-delta.ts';
import { dwellForStep } from '../sim/pacing.ts';
export type StepQueue = {
@@ -43,10 +44,71 @@ export type StepQueue = {
behind(): number;
/** The last step actually shown, for the caption line (#15). Null before anything has been shown. */
showing(): DisplayStep | null;
/**
* The piles the step now on screen moved, for the display to light.
*
* Here because this is the only place that holds both the frame before a step and the frame after
* it — deriving it anywhere else would mean keeping a second copy of the board in step.
*/
lit(): readonly PileKey[];
/** True while there is anything left to show. */
busy(): boolean;
/**
* How many narrated lines belong to steps NOT yet shown.
*
* The log and the board are two different moments while the queue is behind: a push carries its
* narration and its steps together, so every line of a bot's turn is in the history panel before the
* board has drawn a single move of it (playtest, 2026-09-15: *"is it possible to stall history so it
* stays in sync with the number behind?"*). Those lines are the TAIL of the log — they arrived last —
* so the caller holds back exactly this many and reveals each as its step goes up.
*/
pendingLines(): number;
/** Division nodes whose card changed in the step now on screen, for the map to flash. */
flashing(): readonly number[];
/**
* HOLD THE PLAYBACK ON THE STEP NOW SHOWING — Jesse, playtest 2026-09-16, asking for a pause
* beside Skip.
*
* Skip is the only control the row has had, and it is one-way and total: the way to look harder at
* a move that just went past was to not be too slow about it. Pause is the opposite lever — the
* board stops where it is and nothing is consumed, so a player can read the caption, look at the
* district and then carry on from exactly that step.
*
* TAKES `now` BECAUSE THE QUEUE OWNS NO CLOCK (see the note at the top of this file). The dwell
* still owing is preserved across the hold rather than being spent while nobody was watching:
* `resume` pushes the deadline out by however long the pause lasted, so a step paused with 200ms
* left resumes with 200ms left instead of vanishing on the next frame.
*
* Returns false when there is nothing to hold, or nothing being held.
*/
pause(now: number): boolean;
resume(now: number): boolean;
paused(): boolean;
};
/**
* WHOSE MOVE THE SCREEN IS SHOWING (Gitea#25).
*
* The game and the board on screen are two different moments. The server plays every bot move the
* instant a human's turn ends (`driveBots`), so the LIVE game is nearly always waiting on the human —
* while this queue is still replaying the bots, step by step. The turn chart and the Division map's
* move marker read the live actor, so a table of one person and three bots said "waiting on" that
* person throughout, against a playback row naming the bot actually moving.
*
* While the queue is behind or still showing a step, the answer is that step's player — `null` for an
* automatic phase, which is "the Division is running itself". Otherwise it is the live actor, and
* `replaying` is false so a caller can keep live-only detail, such as a ruling the game is waiting on,
* off a screen that has not caught up with it yet.
*/
export function actorOnScreen(
queue: Pick<StepQueue, 'behind' | 'busy' | 'showing'>,
live: number | null,
): { actor: number | null; replaying: boolean } {
if (queue.behind() === 0 && !queue.busy()) return { actor: live, replaying: false };
const shown = queue.showing();
return shown === null ? { actor: live, replaying: false } : { actor: shown.player, replaying: true };
}
/**
* `pace` is read on every step rather than captured, so changing the setting takes effect at once.
*
@@ -65,9 +127,13 @@ export function createStepQueue(
): StepQueue {
let shown: PublicFrame | null = null;
let last: DisplayStep | null = null;
let litPiles: readonly PileKey[] = [];
let flashedCards: readonly number[] = [];
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 =>
@@ -75,8 +141,17 @@ export function createStepQueue(
/** Applies one step to the displayed board. A step's delta chains off the previous step's frame. */
const show = (step: DisplayStep): void => {
const before = shown;
shown = applyPublicDelta(shown, step.frame);
last = step;
/**
* NOT FOR YOUR OWN MOVES. You drew that card; you do not need the deck flashed at you. Same rule
* that gives your own steps no dwell — the display is for watching everybody else.
*/
litPiles = step.player !== null && step.player === viewer() ? [] : changedPiles(before, shown);
// A Realignment changes the Division under everyone, so it is flashed for the player who did it
// too — unlike a pile, which only tells the drawer what they already know.
flashedCards = changedDivisionCards(before, shown);
};
return {
@@ -84,6 +159,12 @@ 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 = [];
// `last` deliberately survives: a reconnect should not blank the caption line, and the
// sentence describing the most recent action is still true.
},
@@ -93,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
@@ -126,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 = [];
@@ -133,9 +220,28 @@ 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,
lit: () => litPiles,
/**
* STILL SHOWING SOMETHING, not just still holding something back.
*
@@ -147,6 +253,8 @@ export function createStepQueue(
* `dueAt` is non-null exactly while the step on screen has time left, so the two together mean
* "there is more to come, or what is up has not had its moment yet".
*/
pendingLines: () => pending.reduce((n, s) => n + s.lines.length, 0),
flashing: () => flashedCards,
busy: () => pending.length > 0 || dueAt !== null,
};
}
+191 -2
View File
@@ -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';
@@ -1141,7 +1141,12 @@ describe('the history says WHY a train moved, and says it truthfully', () => {
// A train ahead of it in the same Subdivision, running the SAME way — §8.1's fourth condition,
// which is the Superintendent's call rather than an absolute bar.
const ahead = s.division.nodes.findIndex((n) => n.kind === 'mainline');
//
// AHEAD MEANS EAST OF THE OFFICE for this eastbound train. This used to take the FIRST Mainline card
// in the Division, which is west of the Office — behind the train — and still expected a ruling,
// which is exactly the fault Gitea#26 reported. The card is now one the train would actually follow.
const office = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
const ahead = s.division.nodes.findIndex((n, i) => i > office && n.kind === 'mainline');
const node = s.division.nodes[ahead];
assert.equal(node?.kind, 'mainline');
s.trays.set('ahead', {
@@ -1511,3 +1516,187 @@ describe('an Extra starts where the player puts it (Gitea#4)', () => {
assert.equal(check(s, 0, at), 'NO_EXTRA_PENDING');
});
});
// ---------------------------------------------------------------------------
describe('§8.1 counts only trains AHEAD of the one departing (Gitea#26)', () => {
/**
* REPORTED from playtesting v0.8.0.9: two westbound Extras, X15 at an Office and X18 still crossing
* the card to its EAST. The Superintendent was asked to rule on X15 against X18 — a train behind it —
* and holding X15 kept the Whistle Post's only A/D track full, so X18 arrived into it and was
* destroyed. Reproduced by replaying the exported save; the positions below are that situation in a
* one-seat Division, where every Office is a Whistle Post and the Subdivision spans them all.
*/
const setup = (occupant: { direction: 'east' | 'west'; side: 'east' | 'west'; number: number }) => {
const s = game(7, { days: 5 });
const area = areaOf(s, 0);
const office = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
s.trays.set('departing', {
id: 'departing', trainNumber: 15, trainIsExtra: true, engineAt: 0, consist: [],
direction: 'west', facing: 'w',
position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
});
area.adOccupancy.push('departing');
const card = s.division.nodes.findIndex((n, i) =>
n.kind === 'mainline' && (occupant.side === 'east' ? i > office : i < office));
const node = s.division.nodes[card];
assert.equal(node?.kind, 'mainline');
s.trays.set('other', {
id: 'other', trainNumber: occupant.number, trainIsExtra: true, engineAt: 0, consist: [],
direction: occupant.direction, facing: occupant.direction === 'east' ? 'e' : 'w',
position: { at: 'mainline', index: card }, movesUsed: 0,
});
if (node?.kind === 'mainline') {
node.transits.push({ tray: 'other', stagesRemaining: 2, stagesTotal: 2, direction: occupant.direction });
}
s.clock.phase = 'mainline';
return s;
};
it('does not put a same-direction train BEHIND the departing one to the Superintendent', () => {
const s = setup({ direction: 'west', side: 'east', number: 18 });
const r = advance(s);
assert.ok(!r.events.some((e) => e.type === 'clearanceRequested'), 'a train behind was put to the Superintendent');
assert.ok(
r.events.some((e) => e.type === 'trainHighballed' && e.trainNumber === 15),
'the departing train did not highball with nothing ahead of it',
);
assert.ok(!r.events.some((e) => e.type === 'trainsDestroyed'), 'a train was destroyed');
});
it('does not bar a departure over an opposite-direction train BEHIND it, which is moving away', () => {
const s = setup({ direction: 'east', side: 'east', number: 18 });
const r = advance(s);
assert.ok(
r.events.some((e) => e.type === 'trainHighballed' && e.trainNumber === 15),
'a train moving away behind it held the departure',
);
});
it('still puts a same-direction train AHEAD to the Superintendent', () => {
const s = setup({ direction: 'west', side: 'west', number: 18 });
const r = advance(s);
assert.ok(
r.events.some((e) => e.type === 'clearanceRequested' && e.trainId === 'departing'),
'a train the departing one would follow was not put to the Superintendent',
);
});
});
// ---------------------------------------------------------------------------
/**
* §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}`);
});
});
+89 -10
View File
@@ -159,6 +159,60 @@ describe('Local Operations: drawing (§6.2)', () => {
assert.notEqual(s.decks.departments[1]![0], target, 'refilled with the same card');
});
it('a PLAYED timetabled train never comes back, but a discarded one does — Gitea#23', () => {
/**
* Jesse's ruling, 2026-09-10: *"Once you've played a regularly scheduled train and it's in the
* salvage deck, that train is already on the timetable. It does not make sense to put that back
* into a reshuffled home deck to get played again. By contrast, a regularly scheduled train
* that's in a discard pile could potentially get reused later, and so should have that
* capability. Extras run one time and then they're done — if they are in the Salvage deck, they
* should get shuffled back in so that they could get run again."*
*
* So the test is WHERE the card is, not only what it is: the same card is spent in the Salvage
* Yard and still runnable in a Department. That is what this pins, because it is the kind of rule
* a later tidy-up would happily "simplify" into filtering by card kind everywhere.
*/
const s = game();
const kindOfCard = (id: string): string => s.cards.get(id)?.kind.kind ?? '?';
const pool = [...s.decks.homeOffice];
const trains = pool.filter((id) => kindOfCard(id) === 'timetabledTrain');
const extras = pool.filter((id) => kindOfCard(id) === 'extraTrain');
const others = pool.filter((id) => !['timetabledTrain', 'extraTrain'].includes(kindOfCard(id)));
assert.ok(trains.length >= 2 && extras.length >= 1 && others.length >= 5, 'the deal lacks the cards this needs');
const spentTrain = trains[0]!; // played: in the Salvage Yard, its slot taken
const discardedTrain = trains[1]!; // never played: sitting in a Department
const playedExtra = extras[0]!; // a single run, free to run again
s.decks.salvageYard = [spentTrain, playedExtra, ...others.slice(0, 3)];
s.decks.departments = [[discardedTrain], [others[3]!], [others[4]!]];
s.decks.homeOffice = [others[5]!];
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const r = applyIntent(s, 0, { type: 'draw.fromHomeOffice' });
assert.ok(r.ok);
assert.ok(r.events.some((e) => e.type === 'deckReshuffled'), 'no reshuffle was emitted');
const recovered = new Set([...s.decks.homeOffice, ...s.decks.departments.flat()]);
const hands = new Set([...s.decks.hands.values()].flat());
// THE RULING, both halves.
assert.ok(!recovered.has(spentTrain), 'a played timetabled train was shuffled back in');
assert.ok(!hands.has(spentTrain), 'a played timetabled train was dealt back into a hand');
assert.ok(
s.decks.salvageYard.includes(spentTrain),
'a played timetabled train should stay in the Salvage Yard, not vanish',
);
assert.ok(
recovered.has(discardedTrain) || hands.has(discardedTrain),
'a DISCARDED timetabled train must come back — it was never played, so its slot is open',
);
assert.ok(
recovered.has(playedExtra) || hands.has(playedExtra),
'a played Extra must come back — an Extra is one run, not a standing slot',
);
});
it('reshuffles the Salvage Yard and Departments back in when the deck runs out', () => {
// §6.2 — "If drawing a card has depleted the Home Office deck, immediately collect all cards
// from the Salvage Yard and three Department decks, reshuffle, and reestablish the Home Office
@@ -182,7 +236,11 @@ describe('Local Operations: drawing (§6.2)', () => {
assert.ok(r.ok);
assert.ok(r.events.some((e) => e.type === 'deckReshuffled'), 'no reshuffle was emitted');
assert.equal(s.decks.salvageYard.length, 0, 'the Salvage Yard must be swept');
// Swept EXCEPT the trains whose slots are already on the timetable — see the ruling test below.
assert.ok(
s.decks.salvageYard.every((id) => s.cards.get(id)?.kind.kind === 'timetabledTrain'),
'the Salvage Yard must be swept apart from spent timetabled trains',
);
assert.ok(s.decks.homeOffice.length > 0, 'the deck must be re-established');
assert.ok(
s.decks.departments.every((p) => p.length === 1),
@@ -1303,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)
@@ -1317,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);
@@ -1332,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) }),
@@ -1367,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(
@@ -1823,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();
+161
View File
@@ -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(' | ')}`,
);
});
});
+18
View File
@@ -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',
+36 -12
View File
@@ -52,7 +52,7 @@ describe('pacing — dwell by kind', () => {
assert.equal(kindOf('switch.end'), 'bookkeeping');
/**
* `localOps.choose` IS AN ANNOUNCEMENT, not bookkeeping — moved 2026-09-09 after the first real
* play on `phoenix.local`. It is the line reading "Player Bot 1 chose to SWITCH", the heading for
* play on the test server. It is the line reading "Player Bot 1 chose to SWITCH", the heading for
* everything that follows, and at zero dwell a bot's turn began with no sign of what it was about
* to do.
*/
@@ -131,6 +131,12 @@ describe('pacing — dwell by kind', () => {
assert.equal(PACE_LEVELS[0], 0, 'off must be the first rung — #18 wants it turned off');
assert.ok(PACE_LEVELS.includes(1), 'the default must be on the ladder');
assert.ok(PACE_LEVELS.includes(7), '7x was asked for by name');
/**
* The ceiling is not theoretical. Jesse played at 10× — the top of the ladder as it then was —
* and called it "still a bit fast, but followable", so the ladder has to go past the speed
* somebody actually reached for and found insufficient.
*/
assert.ok(PACE_LEVELS.some((p) => p > 10), 'the ladder must go beyond the speed that was too fast');
for (const p of PACE_LEVELS) {
assert.ok(p <= MAX_PACE, `${p}x is past MAX_PACE, so the control would lie about it`);
assert.equal(dwellFor('switch.move', p), Math.round(DWELL.switching * p));
@@ -164,23 +170,41 @@ describe('pacing — dwell by kind', () => {
);
});
it('the speed control stretches other people, not the clock', () => {
it('the speed control stretches the clock at a THIRD of the rate it stretches people', () => {
/**
* Jesse, from a real 5× game: *"after my turn, when I actually execute my turn, I'm still subject
* to that same delay before it moves on. That makes no sense."* It was not his move being
* replayed — it was the automatic phases behind it, which were scaling with `pace` along with
* everything else. Measured over 40 turns, the waiting split almost evenly between other players
* and phases turning over, so a 5× game spent 105 seconds on the clock alone.
* Two complaints, one from each direction, and the answer is between them.
*
* v0.8.0.3, from a 5× game: *"after my turn … I'm still subject to that same delay before it
* moves on. That makes no sense."* — phases were scaling with everything else and walling off a
* player's own turn. So they were pinned at their tabled beat.
*
* v0.8.0.7, from a 10× game: *"phases displayed on the upper line go by too quickly still.
* Should be 4 times as long — at a guess."* — pinned was too short to read the caption.
*
* Damped scaling satisfies both: 1× unchanged, 10× lands exactly on the four-times guess, and
* the cost stays bounded because phase beats cluster rather than accumulate.
*/
const phase = { cause: 'phase' as const, player: null, lines: ['New Train'], frame: { table: { phase: 'newTrain' } } };
const theirs = { cause: 'switch.move' as const, player: 1, lines: ['moved'], frame: { table: {} } };
for (const pace of [1, 3, 5, 7]) {
assert.equal(dwellForStep(phase, pace), DWELL.phase, `a phase beat grew at ${pace}x`);
assert.equal(dwellForStep(theirs, pace), DWELL.switching * pace);
assert.equal(dwellForStep(phase, 1), DWELL.phase, '1x must be exactly the tabled beat');
assert.equal(dwellForStep(phase, 10), DWELL.phase * 4, '10x must be four times it, as asked for');
for (const pace of [2, 3, 5, 7, 10, 15, 20]) {
const p = dwellForStep(phase, pace);
const t = dwellForStep(theirs, pace);
assert.ok(p > DWELL.phase, `a phase must grow at ${pace}x`);
assert.ok(
p < DWELL.phase * pace,
`a phase must grow SLOWER than the multiplier at ${pace}x, or the clock walls off the turn`,
);
assert.ok(t > p, `somebody's move must still outlast a phase beat at ${pace}x`);
}
// Off still means off, for the clock as much as for anybody's move.
// Off still means off, for the clock as much as for anybody's move; and below 1x the clock
// follows the multiplier straight, because "faster" should mean everything.
assert.equal(dwellForStep(phase, 0), 0);
assert.equal(dwellForStep(theirs, 0), 0);
assert.equal(dwellForStep(phase, 0.5), DWELL.phase * 0.5);
});
it('a real switching turn is watchable in a few seconds, not tens of them', () => {
@@ -199,7 +223,7 @@ describe('pacing — dwell by kind', () => {
it("a bot's ordinary turn is followable, which is what the first real play was not", () => {
/**
* MEASURED FROM A REAL GAME, then pinned. Jesse, after installing v0.8.0 on `phoenix.local`:
* MEASURED FROM A REAL GAME, then pinned. Jesse, after installing v0.8.0 on the test server:
* *"bot play was way too fast. I briefly saw that it was the bot's office area then their turn
* was done."* This is the shape that turn actually had — no switching in it at all, because
* switching is not legal until there is track down — and under the original values it came to
+109 -1
View File
@@ -17,7 +17,7 @@ import type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.ts'
import { applyIntent } from '../src/engine/apply.ts';
import { currentActorOfState, publicSnapshot } from '../src/sim/view.ts';
import type { PublicFrame } from '../src/sim/view.ts';
import { applyPublicDelta, deltaPublicFrame } from '../src/sim/public-delta.ts';
import { applyPublicDelta, changedPiles, deltaPublicFrame } from '../src/sim/public-delta.ts';
const config: GameConfig = {
mode: 'competitive',
@@ -184,3 +184,111 @@ describe('public frame delta', () => {
});
type PublicIndexed = { seat: number; before: PublicFrame; after: PublicFrame };
describe('which piles a step moved', () => {
/**
* MEASURED FROM REAL PLAY, then pinned. The table in `changedPiles` claims what each action moves,
* and a claim in a comment is worth nothing unless something checks it — so this drives real games
* and asserts the mapping holds, action by action.
*/
it('maps each action to the piles it actually touches', () => {
const seen = new Map<string, Set<string>>();
/**
* TWO PASSES, because a single driver cannot reach every case. Left to itself the bot almost
* never takes a Department card, and a driver that prefers one then never draws from the deck —
* so each preference is played out separately and the assertions below require BOTH to have
* been observed rather than passing on whichever happened to occur.
*/
for (const prefer of ['draw.fromDepartment', 'draw.fromHomeOffice'] as const) {
for (const seed of [1917398, 191056, 4242]) {
const s = newState(seed);
for (let i = 0; i < 400; i++) {
const actor = currentActorOfState(s);
if (actor === null) break;
const options = legalActions(s, actor);
if (options.length === 0) break;
const chosen =
options.find((o) => o.type === prefer) ??
options.find((o) => o.type === 'card.discard') ??
options[i % options.length]!;
const before = publicSnapshot(s);
const r = applyIntent(s, actor, chosen);
if (!r.ok) break;
pump(s);
const piles = changedPiles(before, publicSnapshot(s)).map((p) => p.replace(/dept\d/, 'dept'));
if (!seen.has(chosen.type)) seen.set(chosen.type, new Set());
for (const p of piles) seen.get(chosen.type)!.add(p);
if (piles.length === 0) seen.get(chosen.type)!.add('(none)');
}
}
}
const of = (t: string): Set<string> => seen.get(t) ?? new Set();
// NOT VACUOUS: the four cases the mapping is actually about must all have happened.
for (const needed of ['draw.fromHomeOffice', 'draw.fromDepartment', 'card.discard', 'card.play']) {
assert.ok(of(needed).size > 0, `${needed} never occurred, so its rule proved nothing`);
}
// A HOME OFFICE DRAW MOVES THE COUNT AND NOTHING ELSE ON A PILE. The card is private; the deck
// getting shorter is not, and it is the only thing a watcher can be shown.
assert.deepEqual([...of('draw.fromHomeOffice')].sort(), ['home']);
// A DEPARTMENT DRAW touches that Department, and sometimes the deck too — the pile refills from
// it. Both are public, so both may light.
for (const p of of('draw.fromDepartment')) {
assert.ok(p === 'dept' || p === 'home', `a Department draw moved "${p}"`);
}
assert.ok(of('draw.fromDepartment').has('dept'), 'a Department draw must light its Department');
// A DISCARD lands face up on a Department, and which one is public.
assert.deepEqual([...of('card.discard')].sort(), ['dept']);
// A PLAYED CARD that does not stay on the board lands face up in the Salvage Yard.
assert.ok(of('card.play').has('salvage'), 'a played card must be able to light the Salvage Yard');
// ENDING A PHASE moves no card anywhere, so nothing should light for it.
for (const quiet of ['draw.end', 'loadUnload.end', 'switch.end', 'localOps.choose']) {
if (of(quiet).size > 0) assert.deepEqual([...of(quiet)], ['(none)'], `${quiet} lit a pile`);
}
});
it('lights nothing without a previous frame to compare against', () => {
// A reset has nothing to have watched arriving, so nothing on it is lit.
const s = newState(4242);
assert.deepEqual(changedPiles(null, publicSnapshot(s)), []);
});
});
describe('a Mainline card that changed under the players (Gitea#28)', () => {
it('names the node a Realignment converted, and nothing else', async () => {
const { changedDivisionCards } = await import('../src/sim/public-delta.ts');
const { REALIGNMENTS } = await import('../src/engine/content.ts');
const s = newState(4242);
const before = publicSnapshot(s);
// Realignment converts a card to another kind (`content.ts`'s table). Applied to the state directly:
// what is being tested is the DETECTOR, not the play that reaches it — which needs the card in hand,
// the draw option taken and no train on the card.
const at = s.division.nodes.findIndex((n) => n.kind === 'mainline' && REALIGNMENTS.some((r) => r.from === n.card));
assert.ok(at >= 0, 'no Mainline card in this Division can be realigned at all');
const node = s.division.nodes[at];
assert.equal(node?.kind, 'mainline');
if (node?.kind === 'mainline') {
node.card = REALIGNMENTS.find((r) => r.from === node.card)!.to;
}
const after = publicSnapshot(s);
assert.deepEqual(changedDivisionCards(before, after), [at], 'the realigned card was not the one reported');
assert.deepEqual(changedDivisionCards(after, after), [], 'an unchanged Division reported a change');
assert.deepEqual(changedDivisionCards(null, after), [], 'a first board has nothing to compare against');
});
it('says nothing when only the trains on a card moved', async () => {
const { changedDivisionCards } = await import('../src/sim/public-delta.ts');
const s = newState(1917398);
const before = publicSnapshot(s);
const at = s.division.nodes.findIndex((n) => n.kind === 'mainline');
const node = s.division.nodes[at];
if (node?.kind === 'mainline') {
node.transits.push({ tray: 'tray1', stagesRemaining: 1, stagesTotal: 1, direction: 'east' });
}
assert.deepEqual(changedDivisionCards(before, publicSnapshot(s)), [], 'a train arriving flashed the card');
});
});
+44 -3
View File
@@ -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 });
+88
View File
@@ -0,0 +1,88 @@
/**
* The engine's speed-ups must not change a single game (2026-09-15).
*
* `applyIntent` became `prepareIntent` (check and execute, sharing one walk of the position's routes)
* followed by `commitEvents` (reduce and tally), so the switching planner can decide every candidate
* against one position and apply each to a copy. Two properties hold that together:
*
* 1. `prepareIntent` never writes the state it reads — including through the route cache it opens.
* 2. Preparing on one state and committing to an EQUAL copy lands on exactly what `applyIntent` does.
*
* Checked at every decision of seeded bot games rather than on hand-built positions, so the intents
* exercised are the ones real play submits.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { pump } from '../src/engine/advance.ts';
import { applyIntent, commitEvents, prepareIntent } from '../src/engine/apply.ts';
import {
DEFAULT_MAX_COLLISIONS_PER_DAY,
DEFAULT_MAX_COLLISIONS_TOTAL,
collectiveRevenueFloor,
lengthProfile,
} from '../src/engine/content.ts';
import { legalActions } from '../src/engine/legal.ts';
import { createGame } from '../src/engine/setup.ts';
import type { GameConfig, GameState } from '../src/engine/state.ts';
import { developerBot, playGame } from '../src/sim/bot.ts';
const config = (): GameConfig => {
const days = lengthProfile('short').days;
return {
mode: 'solitaire',
days,
minCombinedRevenue: collectiveRevenueFloor(1, days),
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
};
};
const serialise = (s: GameState): string =>
JSON.stringify(s, (_k, v) => (v instanceof Map ? [...v] : v instanceof Set ? [...v] : v));
describe('applyIntent split into prepareIntent and commitEvents', () => {
it('prepares without writing, and committing to a copy matches applying in place', () => {
let decisions = 0;
let rejectedSeen = 0;
const s = createGame({ id: 'split-8919', seed: 8919, config: config(), playerNames: ['bot'] });
const policy = {
name: 'split-probe',
choose(st: GameState, player: number, options: ReturnType<typeof legalActions>) {
const chosen = developerBot.choose(st, player, options);
if (decisions < 400) {
decisions++;
const before = serialise(st);
const prepared = prepareIntent(st, player, chosen);
assert.equal(serialise(st), before, `decision ${decisions}: prepareIntent wrote into the state it read`);
assert.ok(prepared.ok, `decision ${decisions}: a legal choice was refused by prepareIntent`);
const viaCommit = structuredClone(st);
const viaApply = structuredClone(st);
commitEvents(viaCommit, prepared.events);
const applied = applyIntent(viaApply, player, chosen);
assert.ok(applied.ok);
assert.deepEqual(applied.events, prepared.events, `decision ${decisions}: the two paths produced different events`);
assert.equal(serialise(viaCommit), serialise(viaApply), `decision ${decisions}: committing to a copy diverged from applying`);
// A refused intent must come back refused from both paths, with nothing written.
const refused = { type: 'switch.end' } as const;
const r = prepareIntent(st, player, refused);
if (!r.ok) {
rejectedSeen++;
assert.equal(serialise(st), before);
assert.equal(applyIntent(structuredClone(st), player, refused).ok, false);
}
}
return chosen;
},
};
const r = playGame(s, policy, pump);
assert.ok(r.finished, 'the probed game did not finish');
assert.ok(decisions > 0, 'no decision was probed');
assert.ok(rejectedSeen > 0, 'no refused intent was exercised');
});
});
+68
View File
@@ -0,0 +1,68 @@
/**
* Seat recovery codes — Gitea#33.
*
* The properties worth pinning are the ones that make a code safe to put in a link: it is spendable
* exactly once, it stops working on its own, and a bad code is indistinguishable from a spent one.
* `now` is a parameter rather than a clock, so expiry is tested without faking timers.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { CLAIM_TTL_MS, createClaimStore } from '../../src/server/claims.ts';
describe('seat recovery codes', () => {
it('mints a code that names the seat it was minted for', () => {
const claims = createClaimStore();
const { code, expiresAt } = claims.mint('tok-abc', 'game-1', 1000);
assert.equal(expiresAt, 1000 + CLAIM_TTL_MS);
assert.deepEqual(claims.redeem(code, 1000), { token: 'tok-abc', gameId: 'game-1' });
});
it('spends a code exactly once — a link in a chat log is worth nothing afterwards', () => {
const claims = createClaimStore();
const { code } = claims.mint('tok-abc', 'game-1', 0);
assert.ok(claims.redeem(code, 1));
assert.equal(claims.redeem(code, 2), null, 'the same code was accepted twice');
});
it('stops working once its time is up, without anything having to sweep it', () => {
const claims = createClaimStore();
const { code } = claims.mint('tok-abc', 'game-1', 0);
assert.equal(claims.redeem(code, CLAIM_TTL_MS - 1)?.token, 'tok-abc', 'expired early');
const again = claims.mint('tok-abc', 'game-1', 0).code;
assert.equal(claims.redeem(again, CLAIM_TTL_MS), null, 'a code outlived its expiry');
});
it('answers the same way for unknown, spent and expired codes', () => {
const claims = createClaimStore();
const { code } = claims.mint('tok-abc', 'game-1', 0);
claims.redeem(code, 1);
const expired = claims.mint('tok-abc', 'game-1', 0).code;
assert.equal(claims.redeem('never-existed', 1), null);
assert.equal(claims.redeem(code, 1), null);
assert.equal(claims.redeem(expired, CLAIM_TTL_MS + 1), null);
});
it('gives every mint its own code', () => {
const claims = createClaimStore();
const codes = new Set([0, 1, 2, 3, 4].map(() => claims.mint('tok-abc', 'game-1', 0).code));
assert.equal(codes.size, 5, 'two mints produced the same code');
});
it('forgets expired codes rather than accumulating them', () => {
const claims = createClaimStore();
claims.mint('tok-a', 'game-1', 0);
claims.mint('tok-b', 'game-1', 0);
assert.equal(claims.outstanding(0), 2);
assert.equal(claims.outstanding(CLAIM_TTL_MS), 0, 'expired codes were still being held');
});
it('keeps a short-lived code short-lived when asked for one', () => {
const claims = createClaimStore();
const { code, expiresAt } = claims.mint('tok-abc', 'game-1', 500, 60_000);
assert.equal(expiresAt, 60_500);
assert.equal(claims.redeem(code, 60_500), null);
});
});
+150 -2
View File
@@ -15,7 +15,7 @@ import { currentActor, newMultiplayerGame, submit } from '../src/web/game.ts';
import { publicSnapshot } from '../src/sim/view.ts';
import { takeSteps } from '../src/sim/display-step.ts';
import type { DisplayStep } from '../src/sim/display-step.ts';
import { createStepQueue } from '../src/web/step-queue.ts';
import { actorOnScreen, createStepQueue } from '../src/web/step-queue.ts';
import { DWELL } from '../src/sim/pacing.ts';
const config: GameConfig = {
@@ -109,6 +109,61 @@ describe('the step queue', () => {
assert.equal(q2.behind(), 4, 'and must be replaced once its dwell is up');
});
/**
* 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.
@@ -179,7 +234,7 @@ describe('the step queue', () => {
* REGRESSION. `busy()` was `pending.length > 0`, so the instant the final step of a burst was
* shown the queue reported idle: the animation loop stopped and the district panel snapped back
* to the viewer's own board without that step ever being looked at. Jesse, from the first real
* play on `phoenix.local`: *"I briefly saw that it was the bot's office area then their turn was
* play on the test server: *"I briefly saw that it was the bot's office area then their turn was
* done and it pointed back to my office area"*, and the countdown row appeared "very briefly".
*
* The panel follows `busy()`, so this is the property that keeps somebody else's board on screen
@@ -251,6 +306,25 @@ describe('the step queue', () => {
assert.ok(q.showing() !== null, 'the caption should survive a reset');
});
it('can always be emptied, so a player is never stranded behind it', () => {
/**
* "Your Move" is put away while the board is catching up (v0.8.0.6), which makes `busy()` the
* thing standing between a player and their own turn. So the ways it can be cleared matter more
* than they did: `skip()` must always work, from any state, including one where the clock has
* never advanced — which is exactly the situation a page with no `requestAnimationFrame` is in,
* and how this was found.
*/
const { steps, final } = realSteps(1917398, 400);
const q = createStepQueue();
q.reset(baseline(1917398));
q.push(steps);
// Never advanced at all: no frame has been shown, and the queue is full.
assert.equal(q.busy(), true);
assert.equal(q.skip(), true, 'a never-advanced queue must still be skippable');
assert.equal(q.busy(), false, 'and must be idle afterwards, or the player stays locked out');
assert.deepEqual(q.current(), final);
});
it('draws nothing before a reset has arrived', () => {
const q = createStepQueue();
assert.equal(q.current(), null);
@@ -259,3 +333,77 @@ describe('the step queue', () => {
assert.equal(q.showing(), null);
});
});
describe('whose move the screen is showing (Gitea#25)', () => {
const step = (player: number | null) => ({ player }) as DisplayStep;
const queue = (behind: number, busy: boolean, showing: DisplayStep | null) => ({
behind: () => behind,
busy: () => busy,
showing: () => showing,
});
it('names the live actor once the board has caught up', () => {
assert.deepEqual(actorOnScreen(queue(0, false, step(2)), 0), { actor: 0, replaying: false });
});
it('names the player of the step on screen while the board is behind — not the live actor', () => {
// One human (seat 0) against bots: the live game already waits on seat 0 while bot 2's moves replay.
assert.deepEqual(actorOnScreen(queue(3, true, step(2)), 0), { actor: 2, replaying: true });
});
it('keeps naming the last step while it is still on screen, after the counter reaches zero', () => {
assert.deepEqual(actorOnScreen(queue(0, true, step(1)), 0), { actor: 1, replaying: true });
});
it('names nobody for an automatic phase being shown', () => {
assert.deepEqual(actorOnScreen(queue(2, true, step(null)), 0), { actor: null, replaying: true });
});
it('falls back to the live actor before any step has been shown', () => {
assert.deepEqual(actorOnScreen(queue(1, true, null), 3), { actor: 3, replaying: false });
});
});
describe('the log is held back with the board, and a changed card flashes (playtest, 2026-09-15)', () => {
it('owes exactly the lines of the steps not yet shown', () => {
const { steps } = realSteps(1917398, 400);
const q = createStepQueue();
q.reset(baseline(1917398));
assert.equal(q.pendingLines(), 0, 'an empty queue holds nothing back');
q.push(steps);
const owed = steps.reduce((n, s) => n + s.lines.length, 0);
assert.equal(q.pendingLines(), owed, 'every queued step still owes its lines');
// Drive the clock as a render loop would; the debt falls monotonically and ends at nothing.
let now = 0;
let last = owed;
for (let i = 0; i < 20_000 && q.busy(); i++) {
q.advance(now);
const left = q.pendingLines();
assert.ok(left <= last, 'the held-back count grew while the board caught up');
last = left;
now += 50;
}
assert.equal(q.pendingLines(), 0, 'the board caught up but lines were still withheld');
});
it('skipping reveals the whole log at once', () => {
const { steps } = realSteps(1917398, 400);
const q = createStepQueue();
q.reset(baseline(1917398));
q.push(steps);
q.skip();
assert.equal(q.pendingLines(), 0, 'Skip left lines withheld — the history would stay short');
});
it('flashes nothing on an ordinary step', () => {
// Realignment is rare in bot play, so this pins the quiet case: the map must not pulse at random.
const { steps } = realSteps(1917398, 400);
const q = createStepQueue();
q.reset(baseline(1917398));
q.push(steps.slice(0, 5));
q.advance(0);
assert.deepEqual(q.flashing(), [], 'a step that changed no Mainline card flashed one');
});
});
+100
View File
@@ -0,0 +1,100 @@
/**
* The switching planner (`sim/switch-planner.ts`) — the two properties it cannot be allowed to lose.
*
* 1. PLANNING TOUCHES NOTHING. The planner applies intents to a partial copy of the game
* (`forkForSwitching`) that shares everything a switching intent is not supposed to write. If a
* reducer ever starts writing somewhere new, the copy leaks into the real game, and this is where
* that shows: the game is serialised before and after planning and must not have changed.
* 2. A PLAN IS WHAT THE ENGINE WILL DO. Every step replays through `applyIntent` on a FULL copy, and
* lands on the fingerprint the planner promised for it. That is what makes the partial copy
* trustworthy, and it is what the bot relies on to know it is still on plan.
*
* Taken from real seeded bot games rather than hand-built positions, because a district that
* satisfies the track geometry by hand tests the builder as much as the planner.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { pump } from '../src/engine/advance.ts';
import { applyIntent } from '../src/engine/apply.ts';
import { legalActions, legalSwitchingActions } from '../src/engine/legal.ts';
import {
DEFAULT_MAX_COLLISIONS_PER_DAY,
DEFAULT_MAX_COLLISIONS_TOTAL,
MOVES_PER_LOCAL_OPS,
collectiveRevenueFloor,
lengthProfile,
} from '../src/engine/content.ts';
import { createGame } from '../src/engine/setup.ts';
import type { GameConfig, GameState } from '../src/engine/state.ts';
import { actingPlayer, turnOf } from '../src/engine/state.ts';
import { developerBot, playGame } from '../src/sim/bot.ts';
import { planSwitchingTurn, switchFingerprint } from '../src/sim/switch-planner.ts';
const config = (): GameConfig => {
const days = lengthProfile('short').days;
return {
mode: 'solitaire',
days,
minCombinedRevenue: collectiveRevenueFloor(1, days),
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
};
};
const serialise = (s: GameState): string =>
JSON.stringify(s, (_k, v) => (v instanceof Map ? [...v] : v instanceof Set ? [...v] : v));
describe('switching planner', () => {
it('asks for switching intents that are exactly the switching subset of legalActions, in order', () => {
const SWITCHING = new Set(['switch.move', 'switch.dropCars', 'switch.sortConsist', 'maneuver.flyingSwitch', 'switch.end']);
let compared = 0;
for (const seed of [1000, 8919]) {
const s = createGame({ id: `legal-${seed}`, seed, config: config(), playerNames: ['bot'] });
playGame(s, developerBot, pump, 50_000, undefined, (st) => {
const p = actingPlayer(st);
if (p === null || st.clock.phase !== 'localOps') return;
const all = legalActions(st, p).filter((i) => SWITCHING.has(i.type));
assert.deepEqual(legalSwitchingActions(st, p), all);
compared++;
});
}
assert.ok(compared > 0, 'no Local Operations decision was reached');
});
it('never changes the game it plans for, and every plan replays to the position it promised', () => {
let checked = 0;
let withSteps = 0;
for (const seed of [1000, 8919, 16838]) {
const s = createGame({ id: `plan-${seed}`, seed, config: config(), playerNames: ['bot'] });
const r = playGame(s, developerBot, pump, 50_000, undefined, (st) => {
const p = actingPlayer(st);
if (p === null || st.clock.phase !== 'localOps') return;
const turn = turnOf(st, p);
if (turn.option !== 'switch' || turn.movesRemaining !== MOVES_PER_LOCAL_OPS) return;
const before = serialise(st);
const plan = planSwitchingTurn(st, p, { budget: 400, beam: 16 });
assert.equal(serialise(st), before, `seed ${seed}: planning wrote into the real game`);
assert.ok(plan.score >= plan.rootScore, 'a plan is never worse than stopping where the crew stands');
const copy = structuredClone(st);
plan.steps.forEach((step, n) => {
assert.equal(switchFingerprint(copy, p), plan.keys[n], `seed ${seed}: step ${n} started off plan`);
const applied = applyIntent(copy, p, step);
assert.ok(applied.ok, `seed ${seed}: step ${n} (${step.type}) was refused by the engine`);
});
assert.equal(switchFingerprint(copy, p), plan.keys.at(-1), `seed ${seed}: the plan did not end where it said`);
checked++;
if (plan.steps.length > 0) withSteps++;
});
assert.ok(r.finished, `seed ${seed}: a game with the planner switched on did not finish`);
}
assert.ok(checked > 0, 'no switching turn was reached, so nothing was tested');
assert.ok(withSteps > 0, 'every plan was empty, so replay was never exercised');
});
});
+9 -5
View File
@@ -151,10 +151,14 @@ describe('the tally counts every event exactly once (Gitea#16)', () => {
it('splits Revenue into what was earned and what was given back', () => {
// Reconciliation is the real assertion and it holds for any game, earned or not: gained minus
// lost IS the score the engine kept. Seed 42 is named because it is one where Revenue actually
// lost IS the score the engine kept. Seed 44 is named because it is one where Revenue actually
// moves in both directions — it earns 1 and gives back 5 to a collision — so the two halves are
// being told apart rather than both sitting at zero.
for (const seed of [1, 7, 42]) {
//
// It was seed 42 until v0.8.0.10. That game's collision was the Superintendent holding a train over
// one BEHIND it (Gitea#26); with the ruling gone the collision is too, and seed 42 now earns 5 and
// loses nothing — a better game and a vacuous test. The seed moved, not the assertion.
for (const seed of [1, 7, 44]) {
const { state } = playKeepingEvents(seed);
const me = state.tally.byPlayer[0]!;
assert.equal(
@@ -163,10 +167,10 @@ describe('the tally counts every event exactly once (Gitea#16)', () => {
`seed ${seed}: gained minus lost does not reconcile with the score the engine kept`,
);
}
const { state } = playKeepingEvents(42);
const { state } = playKeepingEvents(44);
const me = state.tally.byPlayer[0]!;
assert.ok(me.revenueGained > 0, 'seed 42 earned nothing — the gained half is not being counted');
assert.ok(me.revenueLost > 0, 'seed 42 lost nothing — the lost half is not being counted');
assert.ok(me.revenueGained > 0, 'seed 44 earned nothing — the gained half is not being counted');
assert.ok(me.revenueLost > 0, 'seed 44 lost nothing — the lost half is not being counted');
});
it('records a Circus set-up as the one-off it is, not as a streak', () => {
+261 -3
View File
@@ -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++;
}
}
@@ -309,3 +324,246 @@ describe('steps reach a seated player — TODO #13', () => {
assert.ok(seen > 0, 'no steps reached a push, so this proved nothing');
});
});
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');
const { legalActions } = await import('../src/engine/legal.ts');
const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']);
for (let i = 0; i < 400; i++) {
const actor = currentActor(game);
if (actor === null) break;
const options = legalActions(game.state, actor);
if (options.length === 0) break;
if (!submit(game, options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
}
assert.ok(game.log.length > 50, 'the game barely ran, so this proved little');
for (const line of game.log) {
// `record()` prefixes the acting player's NAME; a narration that also named them read
// "Player Alice player 0 finished Local Operations" (playtest, 2026-09-15).
assert.doesNotMatch(
line.text,
/\bplayer \d+\b/i,
`a line still carries a bare player index: ${line.text}`,
);
}
});
it('attributes a clearance ruling to the office, not to the seat\'s own turn', async () => {
const { newMultiplayerGame, drain, submit } = await import('../src/web/game.ts');
const { areaOf } = await import('../src/engine/apply.ts');
const game = newMultiplayerGame(7, config, ['Alice', 'Bob', 'Carol']);
const s = game.state;
const area = areaOf(s, 0);
// A westbound train at seat 0's Office, and another westbound AHEAD of it — west of the Office —
// which is §8.1's fourth condition and the Superintendent's to rule on (see Gitea#26).
s.trays.set('departing', {
id: 'departing', trainNumber: 15, trainIsExtra: true, engineAt: 0, consist: [],
direction: 'west', facing: 'w',
position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
} as never);
area.adOccupancy.push('departing');
const office = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
const card = s.division.nodes.findIndex((n, i) => i < office && n.kind === 'mainline');
const node = s.division.nodes[card];
assert.equal(node?.kind, 'mainline');
s.trays.set('ahead', {
id: 'ahead', trainNumber: 9, trainIsExtra: false, engineAt: 0, consist: [],
direction: 'west', facing: 'w', position: { at: 'mainline', index: card }, movesUsed: 0,
} as never);
if (node?.kind === 'mainline') {
node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
}
s.clock.phase = 'mainline';
drain(game);
assert.equal(s.clock.pendingDecision?.kind, 'clearance', 'no ruling was called for, so nothing was tested');
const before = game.log.length;
assert.ok(submit(game, { type: 'mainline.clearance', allow: false }, s.clock.superintendent));
const said = game.log.slice(before).map((l) => l.text);
assert.ok(
said.some((text) => text.startsWith('Superintendent Player ')),
`a ruling did not read as the office's: ${said.join(' | ')}`,
);
});
});
// ---------------------------------------------------------------------------
/**
* 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}`);
});
});
+188 -5
View File
@@ -23,7 +23,7 @@ import { variantsFor } from '../src/engine/track.ts';
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
import type { DivisionView } from '../src/sim/view.ts';
import { ENHANCEMENT_RULES, STAGES_PER_DAY } from '../src/engine/content.ts';
import { dayEndHtml, facilitiesHtml, resultsHtml, timetableHtml } from '../src/web/panels.ts';
import { dayEndHtml, facilitiesHtml, pilesHtml, resultsHtml, timetableHtml } from '../src/web/panels.ts';
import { turnChartHtml } from '../src/sim/turnchart.ts';
import { fieldSelectors } from '../src/web/settings-form.ts';
import { record, renderHtml } from '../src/sim/replay.ts';
@@ -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', () => {
@@ -3614,6 +3669,68 @@ describe('the Day rolling over says so (Gitea#10)', () => {
assert.ok(html.includes('3 Days left'), `the Days remaining are wrong:\n${html}`);
});
it('draws the Home Office deck, face down, with its count', () => {
/**
* `f.deck` has carried the face-down count since the Frame existed and NOTHING drew it — the
* exact display gap `test/display-gaps.test.ts` sweeps for, surviving in the panel that draws
* every other pile. Asked for by Jesse 2026-09-10, who also wanted somewhere for a draw to
* flash: taking a card off this deck is the commonest move nobody can see.
*/
const s = createEngineGame({
id: 'piles',
seed: 5,
config: {
mode: 'competitive', days: 5, minCombinedRevenue: 60,
maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['Joe', 'Bot 1'],
});
const f = snapshot(s, [], null);
assert.ok(f.deck > 0, 'the deal should leave cards in the Home Office deck');
const html = pilesHtml(f);
assert.ok(html.includes('Home Office'), `no Home Office pile:\n${html}`);
assert.ok(html.includes(`>${f.deck}<`), 'the face-down count is not shown');
// Face down means the card slot must NOT name a card — that is the whole point of the pile.
assert.ok(html.includes('facedown'), 'the Home Office pile is not marked face down');
assert.ok(html.includes('face down'), 'the card slot should say so rather than naming a card');
// It comes first: a card travels out of here, then onto a Department or the Salvage Yard.
assert.ok(
html.indexOf('Home Office') < html.indexOf('Dept 1'),
'the draw deck should be read before the piles cards land on',
);
});
it('lights only the pile a watched move touched', () => {
const s = createEngineGame({
id: 'piles2',
seed: 5,
config: {
mode: 'competitive', days: 5, minCombinedRevenue: 60,
maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['Joe', 'Bot 1'],
});
const f = snapshot(s, [], null);
assert.equal(pilesHtml(f).includes('pilelit'), false, 'nothing is lit when nothing was watched');
const home = pilesHtml(f, ['home']);
assert.equal((home.match(/pilelit/g) ?? []).length, 1, 'exactly one pile should light');
assert.ok(
home.indexOf('pilelit') < home.indexOf('Dept 1'),
'a Home Office draw must light the Home Office pile, not a Department',
);
const dept2 = pilesHtml(f, ['dept1']);
assert.equal((dept2.match(/pilelit/g) ?? []).length, 1);
assert.ok(dept2.indexOf('Dept 2') > dept2.indexOf('Dept 1'), 'order sanity');
// Two piles can move at once — a Department draw that refills from the deck.
assert.equal((pilesHtml(f, ['home', 'dept0']).match(/pilelit/g) ?? []).length, 2);
});
it('reports the ENDED Day\'s collisions, not the fresh Day\'s zero', () => {
/**
* Jesse, 2026-09-09, at the end of a Day 1 with two collisions in it: *"It shows a total of two
@@ -4085,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;
@@ -4143,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.
@@ -4458,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),
@@ -5326,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');
});
});