Compare commits

..
9 Commits
Author SHA1 Message Date
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
Jesse.MarkowitzandClaude Opus 5 ff629c0708 v0.8.0.3 — Skip on the left, a caption that says who, and a clock that stops
stretching

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

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

The caption said what but never who. Measured over 40 turns of a real 3-seat game,
half the waiting is automatic phases — 21.0s of phases against 21.7s of other
players — and a phase narrates as "Mainline", which is accurate and no answer at
all to "who am I waiting on". A phase introduces itself now: "The Division:
Mainline phase". A player's move already carries its name from record(), so it is
left alone. The row was also hiding one step early, because it showed only while
behind > 0 — which goes false exactly when the last step of a burst goes up, so the
step most likely to be read lost its caption.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
2026-09-09 20:08:39 -04:00
38 changed files with 3260 additions and 199 deletions
+753 -1
View File
@@ -19,9 +19,761 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
--- ---
## 0.8.0.10 — 2026-09-15
Three reports from the first multiplayer playtest of v0.8.0.9 — one player and three bots — each traced
to its cause before it was fixed.
### The Superintendent is no longer asked about a train BEHIND the one departing (Gitea#26)
*"Two westbound trains: X15 is further west on Mainline cards than X18. And I still get a Superintendent
must rule... held X15 and then collision happened. X18 destroyed, but X15 fine."*
**Reproduced by replaying the exported save.** At move 78, X15 was highballing west out of seat 3's
Office while X18, also westbound, was still crossing the card to its EAST — behind it. Every Office was a
Whistle Post, so the Subdivision ran the whole railroad, and `evaluateClearance` counted every train in it
without asking which side of the departing train it stood on. The ruling was meaningless; holding X15
kept the Whistle Post's only A/D track full, and X18 arrived into it and was destroyed. Unasked, X15 —
the lower number, so the first to move — would have left and freed the track.
§8.1 asks about a train the departing one would FOLLOW and one moving TOWARDS it, and both are ahead of
it. So a train on a card strictly behind the departing train's own card is no longer counted, in either
pass: a following train behind is no concern, and an oncoming one behind is moving away. A train on the
SAME card is still counted, exactly as before — which of two trains sharing a card is in front is
`entryConflict`'s region question. The other two rulings in the save (moves 37 and 530) were correct and
are unchanged.
A test had the fault written into it: its "train ahead" of an eastbound train was the first Mainline card
in the Division, which is west of the Office. It now stands on a card the train would follow, and three
new cases pin the rule: a same-direction train behind is not put to the Superintendent, an opposite-
direction train behind does not bar the departure, and a same-direction train ahead still is. The tally
test's seed moved from 42 to 44, because the collision seed 42 was chosen for was this bug.
### Games in progress — MANY WILL NOT RESUME
**This release changes when the engine asks for a ruling**, so a save holding a ruling it would no longer
ask for stops replaying at that move: `mainline.clearance` is refused with `NO_PENDING_DECISION`. The
server then refuses to resume that game and logs the move it stopped at, leaving the save untouched —
putting v0.8.0.9 back would resume it. **Measured:** 40 four-seat co-op games recorded by bots under
v0.8.0.9 and replayed under this release — 28 stop, usually 5–40% of the way in, most at a ruling on a
train behind (about ten rulings a game were being asked). The exported playtest game stops at move 78.
**Jesse's call (2026-09-15): no games in progress need keeping, so the fix ships as it is,** with no
per-game switch preserving the old rule.
### Six more from the same playtest (Gitea#27–#32)
**A Realignment says which card it converted (#27).** *"It stated Mainline card 3 converted to plains. It
should state that the mainline card 3 curves was converted to plains."* The event carried only what the
card became, so the line could not name what it had been; `mainlineModified` now carries `from` as well —
events are derived by replaying a save and never stored, so widening one strands nothing — and it reads
"Realignment: Mainline card 3, Curves, converted to Plains".
**And the map flashes it (#28).** *"Is it possible to flash the mainline card when it gets changed by
realignment?"* The one play that changes the Division itself was invisible on the map of it.
`changedDivisionCards` compares the two public boards the animation queue already holds — the same way a
pile is lit — so the pulse lands with the step that shows the change rather than when the intent arrived,
and it is shown to the player who made it too, unlike a lit pile. `prefers-reduced-motion` turns it off.
**The history stops running ahead of the board (#29).** *"Does history display immediately for all bot
turns... is it possible to stall history so it stays in sync with the number behind?"* A push carries its
narration and its steps together, so every line of a bot's turn was in the panel before the board had
drawn any of it. The queue now reports how many lines belong to steps not yet shown, and the panel holds
back exactly those, revealing each as its step goes up. Skip still shows everything at once.
**A ruling reads as the office's (#30).** *"If a player makes a move as a superintendent instead of as
themselves maybe it could say 'Superintendent Player Tom'."* The three moves made by holding the office
rather than in turn — §8.1's clearance, §11's Yard Office offer, §Q's Red Flag prompt — are prefixed that
way. `clearanceGiven` carries no player at all (the office made it, whoever holds it), so the acting seat
is what names it.
**And it names them once (#31).** *"It gives the player's name and then their player number together."*
`record()` prefixes the acting player's NAME for every event carrying `player`, and five narration lines
embedded `Player <index>` themselves — phase ended, actor changed, the Red Flag ruling, the extension vote
and the Yard Office ruling. The name is the log's job; the sentence is the narration's. A test now scans a
played game's whole log for a bare player index.
**A multiplayer game can be saved as a file (#32).** *"In the StartOS actions for the save game, I get a
string I can copy. Most of the time, I want to just save it as a JSON file."* Not possible in the action
itself: an action result member is text only — copyable, QR or masked, with nested groups — and the SDK
has no file member. So it is done where it can be: the play page's "Save replay" button was shown only in
solitaire, because a server-backed session has no local save to hand it. `GET /api/save?token=…` — the
same seat token `/api/stream` and `/api/intent` use, not the administrative secret — hands a seated player
their own game, and the button now appears in multiplayer.
### "Waiting on" follows the move on screen (Gitea#25)
*"Playing against 3 bots — waiting on always says me, even when it is someone else's turn."*
The server plays every bot move the moment a human's turn ends, so the LIVE game is almost always waiting
on the human — while the screen is still replaying those bots step by step. The turn chart and the
Division map's move marker read the live actor, and contradicted the playback row naming the bot actually
moving. `actorOnScreen` (`web/step-queue.ts`) answers with the player of the step on screen while the
board is catching up, and the live actor once it has; during playback the chart also leaves off a live
"asks … ruling" note that belongs to a position the screen has not reached. Five tests pin it.
### The name on the Division map is readable again (Gitea#24)
*"When it shows your player, the font is unreadable... the bold font makes it look fuzzy, and the letters
blur together."*
A name takes the class `bs-turn` while it is that player's move — and `.bs-turn` is also the Division
map's turn ARROW, which strokes its shape 2.4px grey with no fill. The name's own rule changed only the
fill, so every letter was outlined in grey. It showed for the viewing player almost constantly because of
Gitea#25: the map believed it was nearly always their move. `.bs-name` now sets `stroke:none`.
---
## 0.8.0.9 — 2026-09-15
**The developer bot, re-measured decision by decision — and an engine 2.8× faster.** Across the changes
adopted below, each paired against the bot before it, the bot went from about −0.3 revenue a game to
about 4.8: the switching planner +2.89, taking a face-up card only if it could be played +1.52, the
deliberate New Train fallback +0.32, and laying track by what the district can do afterwards +0.12.
### Games in progress
**Resume.** No rule changed, and no legality check changes its answer. The engine's speed-ups were proven
to leave play identical — every event and intent of 32 seeded games hashed before and after each step —
and `test/route-cache.test.ts` pins the new check/commit split. Only the developer bot plays differently,
and a bot's past moves are already in the save.
### The bot plans its switching turn instead of choosing one Move at a time
The switching branch was a ladder of rules picking ONE Move, and its own comment named the gap: "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." `sim/switch-planner.ts` attempts it, for one turn.
**Why search is fair.** A switching turn draws no card and rolls no die, so trying sequences on a copy
of the game is what a player does by looking at the board. The score reads only what a player can see
— the district, the cars on the trains, the facilities — and never the deck. Jesse's line
(2026-09-14): no non-player advantage for the bot.
**Why not every sequence.** Measured 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 costs no Move** and a
crew can leave them in a great many places. So the search keeps the best 48 positions at each step
and stops at 3,000 tried; small turns are searched completely inside that.
**The score is of where the turn ends**, starting from Jesse's ruling that players deliver and pick up
cars even when it delays trains. A car an industry can work is +1 (+0.25 past its box count); a car it
cannot is −0.5 on its track; a finished car left on it −0.15; a wanted car aboard +0.35 or staged on
plain track +0.2; a coach kept with its train +0.3, stranded −0.3; anything fouling the Office −3; a
train away from the Office −0.25, −1 more if expedited; a train §8.2 would refuse (`badlyMadeUp`, now
exported rather than copied) −0.6. A load made in this district scores nothing at a receiver here.
**The copy is partial.** `forkForSwitching` copies only the arrays a switching intent writes — cars
standing on cards, industry tracks, the district's A/D and held lists, the trays' consists, this turn
and the tally — and shares everything else. It began as a `structuredClone` of the district, which a
profile put at **44% of all planning time**; the targeted copy made a position about three times
cheaper to try. `test/switch-planner.test.ts`
proves across seeded games that planning leaves the real game byte-identical, and that every plan
replays through `applyIntent` on a FULL copy to the exact position it promised.
**Measured, 1600 paired seeds: +2.89 ± 0.18 revenue a game (t = 15.79)**, 733 seeds better, 21 worse.
| | rules | planner |
| --- | --- | --- |
| revenue | −0.05 | 2.83 |
| freight loads + unloads | 0.22 | 1.53 |
| collisions | 0.12 | 0.12 |
| Cargo phases with a car spotted | 5% | 18% |
| … with green box, car and Laborer at one industry | 3% | 11% |
**Split by source over 400 seeds**, because the biggest wins were rescues: freight **+1.21** (t = 13.9),
passengers +0.26, expedite faults **+1.07** (19 of 400 games faulted under the rules, none under the
planner — TODO #53, closed by this), collisions unchanged. **Without the fault rescue it is still
+1.45 ± 0.12 (t = 12.2)** — the switching itself got better, not just the catastrophes rarer. It also
sets out half as many cars (3.6 against 7.0): the ladder was setting cars down and picking them up.
**The worst seeds, traced.** Two of the three lost to "no free A/D track" collisions with both tracks
already held by trains standing at the Office — not trains left away, which is what the score's
`trainAway` weight would have explained. The third never had a coach train at the Office in a
Load/Unload phase at all: a different switching turn draws different cards, and the game diverges.
Neither is a scoring defect found; the full-Office collisions are worth watching.
**How far to search.** The search size was measured rather than guessed, paired over 400 seeds against
the 3000-position, beam-48 search the gain above was measured with:
| budget / beam | revenue against 3000/48 | median / worst per turn |
| --- | --- | --- |
| 3000 / 48 | — | 150 ms / 445 ms |
| **2000 / 32 (adopted)** | −0.02 ± 0.01 (t = −1.68), inside the noise | **69 ms / 222 ms** |
| 1000 / 24 | −0.06 ± 0.02 (t = −2.65) | 53 ms / 104 ms |
Times are from 51 switching turns with other simulations sharing the CPU, so read them as relative.
**A switching-only legal list.** `legal.ts` now exports `legalSwitchingActions`, the switching half of
the Local Operations candidates run through the same `check`, in the same order — so the planner stops
paying for every draw and Freight Agent candidate at each position it tries. A test asserts it equals
`legalActions`' switching subset across real games. It contains no rule; `check` still decides.
**Nothing the simulation tests measure moved backwards.** `sim.test.ts` passes all 35 tests with the
planner as the default, floors unchanged.
**Cost.** A turn is planned once and then played a step per decision, replanned if the position is
ever not the one expected. At a live table that is a synchronous pause inside `driveBots`, well inside
Jesse's bar of half a second before a switch.
**The price is test time.** A standard solitaire game takes ~400 ms with the planner against ~108 ms
without, and `npm test` — which plays well over a thousand bot games in `sim.test.ts` — went from about
150 s to **8 min 6 s** (992 of 992 passing, measured with nothing else running). What is left of a
planned turn's time is the engine itself: applying a move is about half of it and `check` a third.
**And the take rule tripled it again.** With both defaults in, `npm test` passes 992 of 992 in
**21 min 21 s** with nothing else running — the bigger districts the take rule builds (14 → 24 cards,
29 cards played a game) make every game longer to play and every switching turn wider to search.
`noPlanSwitching=1` is the ablation.
### Track goes where it lets the district do something
Jesse's third area, after switching and industries: track for the run-around. With the draw no longer
wasted, ~13 of ~16 pieces a game were still being laid by the draw turn's fallback at the first legal
square, and both fixes to that fallback failed (holding −0.83, `bestTrackLay`'s own score +0.07). So the
limit was the scoring: `bestTrackLay` scores the PIECE, and cannot tell one that opens an industry site
or closes a run-around from one that fills a square.
`bestValuedLay` scores the LAYOUT the piece would leave instead. Each legal lay is placed on a copy of
the district exactly as the reducer places it — `protoCard` and `extendLimitsIfNeeded`, now exported
rather than copied — and worth the change it makes to `layoutValue`: industry sites a crew can reach
(`canPlaceAt`), a closed run-around (the bot's own `descendFrom` walk), ways off the main and reachable
siding, a Running Track straight for Interlocking, and a penalty for a turnout on the main whose leg
joins nothing. `bestTrackLay`'s slot takes the best lay that gains something; the fallback still lays as
often as before — holding starved the district — but the best lay rather than the first.
**+0.118 ± 0.029 revenue a game (t = 4.14) over 6400 paired seeds**, 1,667 better against 1,430 worse.
It needed that many: 400 seeds read +0.17 (t = 1.48) and 1600 read +0.14 (t = 2.58). What it builds is
the larger change — **closed run-arounds in 22 of 60 districts against 9**, industries 1.78 → 1.87, freight
2.54 → 2.65, collisions unchanged, the district 24.2 → 21.9 cards because squares stop being filled with
pieces that build nothing. No slower: a standard game measured faster, the smaller districts leaving
less to search. `noValueLays=1` is the ablation.
**Why so many run-arounds buy so little.** Traced over 40 games: the planner uses the loop — a move ends
on it in 63 of 131 plans where one exists, 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 one-turn planner cannot value it. That is TODO #105.
**How 6400 seeds were measured.** `compare.ts` keeps every game's full statistics for both sides, and the
run was stopped for low memory — as was a first lean attempt, by the machine's background-task guard
rather than by the process: a probe showed no leak (heap flat at 9 MB after GC across 300 paired games).
The figure above comes from a paired script with the same seeds and configuration that keeps only each
seed's revenue difference, run in resumable foreground chunks; its first 1600 seeds read +0.143, matching
`compare.ts`'s +0.14.
### The engine walks each position's routes once — 2.8× faster, every game identical
Jesse's call (2026-09-15): speed up the engine's move checking and applying first, because the live
server runs the same `legalActions` and `applyIntent` for every bot and every player. A CPU profile
with inlining off put the route walk (`reachableDestinations`) at a third of all time and garbage
collection at another third, and most of the walking was repeated work.
- **A route cache scoped to one unchanged position** (`withRouteCache`, `apply.ts`). `legal.ts` walked a
tray's routes to list its moves, `check` walked them again for every one of those moves, and
`execute` walked the chosen move a third time. Now a legal-action listing, and the check-and-execute
of one intent, walk each route once. The cache is keyed to the state object and the walk's inputs
and dropped before `reduce` changes anything, so a hit is exactly what a fresh walk returns.
- **`applyIntent` = `prepareIntent` + `commitEvents`.** The planner decides every candidate against one
position inside one cache and commits each to its own copy; deciding on the copy had re-walked every
route the listing had just walked.
- **Less garbage in the walk itself** (`exploreMoves`): the blocked-square explanations are not built
when only destinations are wanted, the queue is read by index instead of `shift()`, and "has this route
been here" reads the route's own path instead of copying a Set at every step.
**A standard solitaire bot game, 561 ms → 203 ms; a short one, 141 ms → 65 ms.** Proved identical by
hashing every event and intent of 32 seeded games (20 solo standard, 6 three-player standard, 6
two-player short) before and after each change, and pinned by `test/route-cache.test.ts`, which checks
at every decision of a seeded game that `prepareIntent` writes nothing and that committing its events
to a copy equals `applyIntent` in place.
### The test suite is split
Jesse (2026-09-15): `npm test` runs after every change, so it has to stay fast; reducing the simulations'
game counts is not acceptable. `npm test` now runs every file except `test/sim.test.ts`, and
`npm run test:sim` runs the bot simulations on their own, typechecked first.
**Measured with nothing else running, after the engine speed-up: `npm test` 958 of 958 in 1 min 45 s;
`npm run test:sim` 35 of 35 in 6 min 58 s** — against 21 min 21 s for the two together before it.
### Where industries go was not the problem — there was nowhere, and the draw was going in circles
The next thing Jesse named after switching was where industries and enhancements go, then track for the
run-around. Measured before touching either, 30 standard games, and it turned both round:
- **The bot places an industry every time it legally can.** It held an industry card at 1,441 draw
decisions and a legal square existed at 24 of them (2%); it played the industry at all 24 and never
discarded one that had somewhere to go. 0.70 industries stand on a board at game end.
- **Why there was nowhere.** An industry is a plain east-west piece that must join existing track and
may not sit on the Running Track. Asked of `check` square by square, the closest a held card got was
NOT_CONNECTED 1,681 times, OUTSIDE_LIMITS 398, legal 28 — never locked out. Printed districts show why:
the row beside the main fills with 45° curves and turnouts whose east-west ends face occupied squares.
- **And track was not the lever either — its supply was.** At 1,334 decisions holding an industry with
no site, a track lay was legal at only 54; a lay that would open a site existed at 22 and the bot
already chose one at 17. The hand had no track in it.
- **Because the draw was cycling.** The bot took 20.1 timetabled trains and 11.9 industries a game off
the Departments and discarded 20.2 and 11.8: `takingRank` ranked a face-up train 2 whether or not the
A/D cap would let it be played, and a face-up industry 1 whether or not it had a site (one did at 0.17
of those takes). The same card was taken again 28.8 times a game, against 19.6 blind draws.
### The bot takes a face-up card only if it could play it
`takingRank` now ranks a face-up train card 0 when the A/D cap would hold it, and a face-up industry 0
when it is locked out or has no legal site — both asked of the rules the play itself is checked by
(`trainWouldOverfillTheOffice`, and `isLockedOut` with `canPlaceAt` in `industrySiteExists`). Nothing
here is hidden information: the Departments are face up.
**+1.52 ± 0.10 revenue a game (t = 15.59)** over 1600 paired seeds, measured as the ablation
`noPlayableTakes=1` against the new default: 880 seeds worse without it, 211 better. (The first
400-seed read was +1.63 ± 0.19.) Cards played 15.8 → 29.0 a game and the district grows 14.2 → 24.2
cards, because the draws it stopped wasting now bring in track: trains scheduled 1.74 → 2.30, freight
1.51 → 2.54, passengers 1.89 → 2.94. **Collisions rose, 0.12 → 0.24**, with the extra trains — see
TODO #106.
### Rejected: holding track the run-around scoring declined
Traced first: of ~16.4 track pieces a standard game lays, only ~3.3 came from `bestTrackLay`. The
draw turn's "play what is in hand" fallback laid the other ~13 at the FIRST legal square — pieces
`bestTrackLay` had just declined, including 4.0 turnouts and 4.3 straights a game on the main and 1.5
turnouts a row off it. The candidate let the fallback lay track only when nothing else could bring the
hand under the limit. **−0.83 ± 0.16 (t = −5.17)**, 172 worse against 90 better: the district fell
24.1 → 11.7 cards and freight 2.41 → 1.77. `bestTrackLay` declines most pieces, so holding them
starves the district — #59's finding, again, with a fuller hand to hold them in.
### Rejected: laying fallback track where it scores best instead of first
The other half of the same trace: keep laying the ~13 fallback pieces a game, but at `bestTrackLay`'s
best-scoring square (bonus or not) rather than the first legal one. **+0.07 ± 0.13 (t = 0.60), inside
the noise**; freight 2.41 → 2.56 but the district 24.1 → 21.0 cards. With both halves measured, the
limit is `bestTrackLay`'s SCORING — it cannot tell a piece that opens an industry site or advances a
run-around from one that fills a square — not which branch places the piece. Code removed.
### The bot stops running Second Sections by accident
TODO #106 traced under today's defaults: of 20 "no free A/D track" collisions in 60 standard games, no
train had been held before any of them and only 8 of the destroyed were Extras. Six destroyed a train of
the same number as a Second Section run within two Stages, and **all 26 Second Sections the bot ran were
an accident**: when no car was on offer, the New Train phase fell back to `options[0]`, and `legalActions`
lists `newTrain.secondSection` ahead of the Extra starts — so a waiting Extra became a doubled train due
out, into an Office that never had an A/D track to spare. The fallback now takes a car, a pass or the
Extra's start, and never a Second Section or a Red Flag merely because it was listed first.
**+0.32 ± 0.09 revenue a game (t = 3.64)** over 400 paired seeds, 30 better against 12 worse; collisions
0.24 → 0.19. `noDeliberateNewTrain=1` is the ablation.
**Found on the way, for Jesse — a rules question, not changed.** Q9 defines the Second Section as a card
played on a train due out and `content.ts` gives `SECOND_SECTION` one copy, but `buildDeck` never deals it
and `check` asks for no card, so any player can run one for free on every train due out.
### Rejected: planning two switching turns ahead (TODO #105)
Built as `planTwoTurns`: keep the six best ends of a switching turn, remove the trains that highball in
the Mainline Phase in between (on the Office square and made up — their cars leave with them), reset the
Moves, plan the next turn from each, and choose by the position after departures plus 0.8 of what the
next turn adds. **−0.28 ± 0.11 (t = −2.58)**; corrected to charge the expedite fault the gap cannot undo
and a Stage for every train left away, **−0.14 ± 0.06 (t = −2.36)** — 400 paired seeds each. A second turn
has little to find: only 49% of switching turns keep their train for the next, a further turn could have
spotted just 0.7 of the 6.1 wanted cars a game that leave aboard departing trains, and trains left away
cost Stages walking crews home. Dropped at Jesse's call; the measurements are in TODO #105.
### Rejected: starting an Extra the Office cannot take where its run never arrives
TODO #106, re-measured under today's defaults: 20 collisions in 60 standard games (−1.67 revenue a
game), every one "no free A/D track", and 26 Extras forced out by a full hand of them. The bot also took
the first legal start for EVERY Extra — the western Division Point, 158 of 158 — so the candidate chose,
when the committed trains exceeded the A/D tracks, a start whose run never reaches the Office: an Extra
runs away from its start (`resolveExtraStart`), and at the Interchange one way may miss the Office.
**+0.10 ± 0.05 (t = 1.82), 395 of 400 seeds identical**: such a start was on offer at 1 of 25 over-cap
starts — most Divisions have no Interchange, and a Division Point always runs through the Office. Code
removed.
### Rejected: refusing to draw into a forced Extra
With the take rule in, the bot's worst seeds all lost to "no free A/D track". Traced: every one of 23
train plays past the A/D cap in 40 games was an EXTRA, played because the hand held four of them and
the only legal action was `card.play` — §6.2 makes a player over the limit reduce the hand, and an Extra
may never be discarded (`keepReason`), so the cap in `choose` yields rather than leave nothing legal.
The candidate declined to choose the draw option with a full hand of such cards when switching or the
Freight Agent was on offer. **−0.03 ± 0.11 (t = −0.30), inside the noise**: collisions fell
0.26 → 0.20, and development fell with them (cards played 29.0 → 25.6) — the Stage spent avoiding the
draw was a Stage not spent building. Code removed; the trap itself is real and filed.
### Rejected: discarding the card least likely to become playable
When a discard was forced, the candidate picked WHICH card by a keep-value (next Office tier, then
Enhancements, track, trains, and industries with no site or Modifiers with no industry last), weighted
so no pile preference could overturn it. **−1.16 ± 0.12 (t = −9.27)**, 16 better against 119 worse,
cards played 15.7 → 10.4. Not traced; the likeliest reason is that the weight overrode `bestDiscard`'s
pile choice, which exists to avoid burying a face-up card the bot wants. Code removed.
### Flying Switch is searched, and cannot be measured
The planner now considers Flying Switch alongside Moves, set-outs and sorts — its partial copy of the
game carries the hand and the Salvage Yard, which `spendCard` writes, and spending the card costs a
tenth of a point so it is played only when it buys something. **Over 400 paired seeds it changed
nothing, because the card is dealt 0 copies** (not in sheet 5; Jesse, 2026-08-26): across 30 standard
games no Flying Switch was ever drawn. TODO #58's "never fires" is therefore a deck fact, not a bot
one. On by default, so the planner uses the card the day it is dealt again.
### Rejected: letting the planned gain decide whether to switch at all
Whether a Stage goes to switching is decided by `usefulSwitching`, a yes/no reading of the district.
The candidate replaced it with the planner's own answer — plan the turn on a fork before the option is
chosen, and switch only if it gains at least a threshold. Paired over 400 seeds against the planner:
| threshold | revenue | seeds better / worse / identical |
| --- | --- | --- |
| 0.5 | **−0.33 ± 0.06 (t = −5.06)** | 15 / 70 / 315 |
| 0.25 | −0.01 ± 0.05 (t = −0.21) | 21 / 22 / 357 |
| 0.1 | +0.06 ± 0.03 (t = 1.79) | 20 / 13 / 367 |
At 0.5 it refuses turns that only COLLECT — a wanted car picked up scores +0.35 — and those pickups feed
the deliveries after them. Below that it agrees with `usefulSwitching` almost everywhere, because that
rule already says yes exactly when there is a car to deliver or lift, which is when a plan gains. The
decision that would matter is switching against DRAWING or the Freight Agent, and the bot has no value
for either to compare with; that is the next piece of work, not a threshold. It also cost a full
search at nearly every Local Operations decision. Code removed.
### Rejected: discounting a car its industry cannot work yet
A spotted car scored half when its shipper had no load staged or its receiver's red box was full.
**−0.29 ± 0.05 (t = −5.52)** over 400 seeds, 47 worse against 4 better; green boxes stocked fell
15% → 11% of Cargo phases. The reason is the order the bot works in: the Freight Agent stocks a box
only once a car is spotted to receive it (`canStockProductively`), so an industry is "not ready"
precisely because nothing has been delivered — the discount withheld the delivery that makes it ready.
Code removed.
---
## 0.8.0.8 — 2026-09-10
**A played train does not come back. Gitea#23, ruled and closed.**
Jesse, on the question v0.8.0.7 filed rather than answered: *"Once you've played a regularly
scheduled train and it's in the salvage deck, that train is already on the timetable. It does not
make sense to put that back into a reshuffled home deck to get played again. By contrast, a regularly
scheduled train that's in a discard pile could potentially get reused later, and so should have that
capability. Extras run one time and then they're done — if they are in the Salvage deck, they should
get shuffled back in so that they could get run again."*
**The test is WHERE the card is, not only what it is** — which is the part worth writing down, because
it is exactly the rule a later tidy-up would "simplify" into filtering by card kind everywhere. The
same train card is spent in the Salvage Yard and still runnable in a Department:
| card | where | on a reshuffle |
| --- | --- | --- |
| timetabled train | Salvage Yard — it was **played**, its number is on the timetable | stays out |
| timetabled train | a Department — **discarded**, never played, slot still open | comes back |
| Extra | anywhere | comes back; an Extra is one run, not a standing slot |
| everything else | anywhere | comes back, as before |
### And the duplicate that started it
`trainScheduled` was pushing a synthetic `train-<number>` into the Salvage Yard **beside the real
card `cardPlayed` had already put there** — measured: four scheduled trains left eight entries in a
pile holding four cards. Nothing read that id. It inflated the pile's depth, displayed as "a card"
because no such card exists, and would have been swept into the draw deck to be drawn as an id with
nothing behind it. Removed, which retires the whole phantom-id class rather than papering over it —
so v0.8.0.7's `cardName()` resolver for `train-<n>` is gone too, along with its test. Dead code kept
for an id that can no longer exist is worse than no code.
### Games in progress
**Resume.** No predicate changed its answer — nothing that was legal became illegal, and a draw is a
draw whatever is on top of the deck. What differs is the Salvage Yard's depth, which was
double-counting, and what a reshuffle would recover. Reshuffles are effectively unreachable in
ordinary play: eight games driven to 4000 moves across eight seeds produced zero.
Closes #23.
---
## 0.8.0.7 — 2026-09-10
### The Salvage Yard was face up and had nothing to say
*"Why is salvage deck not face up? I should see the card played onto salvage."* It always was — the
tile reads the top card. It just said **"a card"**.
`apply.ts` pushes a **synthetic id** on `trainScheduled`:
```ts
s.decks.salvageYard.push(`train-${e.trainNumber}`);
```
Nothing in `s.cards` matches that, so `cardName()` fell through to its "a card" default — and since a
train is scheduled several times a Day, that id is on top of the pile most of the time. Measured
before touching anything: a session's Salvage tile read `"a card"` from the opening frame through 60
pushes, never once changing, while its depth climbed from 2 to 8.
`cardName()` resolves `train-<n>` now, so the tile reads **Train 3**, **Train 2** as cards land.
Resolved in `sim/view.ts` because this is a NAME, which is that file's job.
**The engine half is filed, not fixed — Gitea#23.** `reshuffleIfDepleted()` sweeps the Salvage Yard
back into the draw deck, so a synthetic id can be shuffled in and drawn into a hand as an id with no
card behind it. Not reachable in ordinary play: eight games driven to 4000 moves across eight seeds
produced **zero** reshuffles. There are two defensible fixes and the choice turns on what that
synthetic id is *for*, which is not a call to make in passing while fixing a label.
### Phases scale with the speed control again — at a third of the rate
*"Phases displayed on the upper line go by too quickly still. Should be 4 times as long — at a guess.
Maybe use the speed multiplier for that too?"*
Two complaints from opposite directions, and the answer is between them. v0.8.0.3 **pinned** phases at
their tabled beat because scaling them walled off a player's own turn — *"that makes no sense"*. At
10× that pinned beat is too short to read the sentence on it.
So they scale, damped to a third of the rate: **1× unchanged, 10× lands exactly on the four-times
guess**, 20× gives 4400ms. The cost stays bounded because phase beats cluster rather than accumulate
— measured over 60 pushes, a push carries **1.0 phase beat on average and 4 at worst**, so the wait
after a move is ~2.4s typical and ~10s at its very worst, against the minutes a full multiplier would
have cost. A player's own move still outlasts a phase beat at every speed, which is the ordering that
matters.
---
## 0.8.0.6 — 2026-09-10
Playing v0.8.0.5: *"saw bot's office area now — much better."* Three things still wrong, and one of
them was mine hiding inside the fix for another.
### Your move is put away while the board is catching up
*"Your actions should be hidden while catching up."* Two reasons, and the second is the one that
changed my mind about a decision taken early in v0.8.0 ("never block input"). The board on screen is
behind the game, so a move offered there is a move against a position that has already moved on — the
menu is computed from the CURRENT state and would be acted on while looking at an older one. And the
screen had grown to four things competing at once: the district, the history, the catching-up row,
and now a lit pile. Taking the action list out of that competition, while there is nothing to decide
anyway, is the cheapest way to quieten it.
Not a block: Skip is one click away at the left of the row, so the wait stays voluntary. The buttons
are replaced by the reason they are gone.
### …which could have locked a player out of their own game
Hiding actions behind `busy()` makes that flag the thing standing between a player and their turn —
and **without `requestAnimationFrame` nothing ever advances the queue, so `busy()` would never
clear.** The action list would have been hidden permanently, with Skip the only way to play.
Caught by `test/web.test.ts`, whose DOM stub has no `rAF` — the same stub that has been proving this
page still starts since long before any of this existed. Two fallbacks now: no `rAF` means draw
everything at once (exactly what `pace = 0` does deliberately), and a queue that throws empties
itself rather than stranding the player. `test/step-queue.test.ts` pins that a never-advanced queue
is still skippable.
### The lit pile was never brief — it was too quiet
*"Never saw decks lighting up… caught one flash deck light up for just a very brief moment, but
couldn't see that with what bot was doing in office area and history and catch up area all at same
time."*
Measured before changing anything: at 10× a pile stays lit for **6997ms**, just under seven seconds.
So the highlight was not brief at all. It was a single 0.45s flash-in over a dark green fill, easy to
miss entirely while looking at the district — a state that settles stops asking to be looked at. It
pulses now for as long as the move is up, with a ring and a glow. The reduced-motion fallback is loud
in a different way rather than simply still, since motion is the whole point here.
### The ceiling was not theoretical
*"At 10× — still a bit fast, but followable."* 10× was the top of the ladder, so the control's
slowest setting was not slow enough for the person using it. `PACE_LEVELS` now runs to 20 and
`MAX_PACE` with it. A control whose limit is reached in ordinary use has the wrong limit, not the
right one held firmly.
---
## 0.8.0.5 — 2026-09-10
**Somewhere to look.** Jesse, playing v0.8.0.4 at 10×: *"many operations still occurred too fast for
me to see."* At 10× an action holds the screen for seven seconds, so this was never about duration —
it was that a bot drawing a card changes one number in a panel nobody is watching, and the board sits
unchanged for those seven seconds. **Raising the dwell was the wrong lever, and it had been pulled
three times.** His diagnosis was the right one: mark WHERE, not longer.
### The Home Office deck was never drawn
`f.deck` has carried the face-down count since the Frame existed and **nothing in `src/web/` read
it** — the exact display gap `test/display-gaps.test.ts` was written to sweep for, surviving in the
one panel that draws every other pile. It is a tile now, first in the row, because that is the order
a card travels: out of the deck, into a hand, then onto a Department or the Salvage Yard. Face down,
so its card slot says so rather than naming one — not knowing what is on top is the point of the
pile.
### What lights, and why that is exactly what is public
The piles a move touched are now lit for as long as that move is on screen. **Derived, never sent**:
the client already holds the frame before a step and the frame after it, so `changedPiles()` is a
diff. Nothing is added to the protocol, nothing can drift out of step with the projection, and the
0.8.1 seatless board gets it for free.
Measured across four seeds rather than reasoned about, and pinned by a test that requires each case
to have actually occurred rather than passing on whichever the bot happened to play:
| action | lights | why that is public |
| --- | --- | --- |
| `draw.fromHomeOffice` | the deck | the count, never the card — a blind draw stays the drawer's |
| `draw.fromDepartment` | that Department, and the deck when it refills | the pile is face up, so the card taken is public |
| `card.discard` | that Department | face up, and which pile it went on is the point |
| `card.play` | the Salvage Yard | where a played card that did not stay on the board lands |
| switching, new trains | nothing here | they move the board, which the district panel already follows |
| `*.end`, `localOps.choose` | nothing | no card moved |
**It is a state, not a flash**, and that distinction is the whole reason it works. The timetable's
existing `.tt-slot.fresh` animates for a fixed 1.5s — right for a die roll nobody is waiting on, and
wrong here, because a step can hold for seven seconds and the animation would be long over before
the pause it belongs to. A brief flash-in marks the moment; the lit border and background stay for
exactly as long as the step is up.
**Not for your own moves.** You drew that card — the same rule that already gives your own steps no
dwell.
---
## 0.8.0.4 — 2026-09-09
**Housekeeping: the test server's name is out of the ten places this session put it.**
Both of this project's repositories allow anonymous clone — checked, not assumed: `info/refs` for
`git-upload-pack` answers 200 for `station-master` and for `station-master-startos` alike, while
`git-receive-pack` answers 401. So everything committed here is public, and the standing rule is that
tracked files carry placeholders rather than real hosts.
Ten mentions added while building v0.8.0 are now "the test server" or "the target hardware":
`CHANGELOG.md`, `docs/plans/jitsi-common-board.md` (three identical deferral banners), `sim/pacing.ts`,
`test/pacing.test.ts` and `test/step-queue.test.ts`. Prose and comments only — no behaviour, and the
quotes they carry are unchanged, because what a player said about bot pacing is the part worth
keeping.
**What is deliberately left, and why it is not an oversight:**
- **Nineteen older mentions**, in entries about v0.7.5, v0.7.6 and v0.7.8 and in `TODO.md`. Rewriting
a changelog after the fact makes the record less true, and these describe verification that
genuinely happened on that machine.
- **`scripts/deploy-web.ts` is FUNCTIONAL, not prose.** It carries the host as the default for
`FB_URL`, so a placeholder there would break the deploy for the person the default exists to serve.
Same for the public address it publishes to. If those should move to required environment variables
with no default, that is a change to how deploying works and wants deciding on its own rather than
being smuggled in beside a comment sweep.
---
## 0.8.0.3 — 2026-09-09
Three things from playing v0.8.0.2, all of them about the row rather than the mechanism.
### Skip was at the wrong end of the row
Jesse: *"the skip button should be on the far left, in front of where it says [the count], so it's
always close to where people are looking."* It was on the far right, and a player's eye is on the
countdown. Moved.
### The caption said what, but never who
*"I saw 2 behind, 1 behind, and then it was caught up, but it didn't tell me what the actual action
was, like who I was waiting on or what they were doing. I knew I was behind, but I wasn't sure what I
was supposed to be looking for."*
The caption was there. It was the wrong half of the sentence. **Measured over 40 turns of a real
3-seat game, half the waiting is automatic phases** — 21.0s of phases against 21.7s of other players —
and a phase narrates as "Mainline", which is accurate and no answer at all to "who am I waiting on".
A phase now introduces itself: **"The Division: ▸ Mainline phase"**. A player's move already carries
its name from `record()`, so it is left alone rather than stuttering it twice.
**And the row was hiding a step early.** It was shown only while `behind > 0` — which goes false the
moment the LAST step of a burst goes up, so the one step a player was most likely to be reading about
lost its caption. It now stays up while the queue is still showing something, and reads "catching up"
once nothing is queued behind.
### The speed control was stretching the clock, not just the other players
*"After my turn, when I actually execute my turn, I'm still subject to that same delay before it
moves on. That makes no sense. Since I've just done my turn, I don't need to wait after it."*
He was right, and it was not his move being replayed — own moves have cost nothing since v0.8.0.1. It
was the automatic phases behind it, which were scaling with `pace` along with everything else. At 5×
that put **105 seconds of clock-ticking** into the game, all of it after a player's own move and none
of it anything to watch.
**`pace` now scales a player's move and leaves a phase at its tabled beat.** The control is labelled
as how long another player's move is held, and that is now what it does. A phase still gets its beat
(TODO #18) and still vanishes entirely at `pace = 0`, because off has to mean off.
---
## 0.8.0.2 — 2026-09-09
Two things found by playing v0.8.0.1 on the test server, neither of them in the mechanism itself.
### `?pace=` never worked, and a whole game was played at the wrong speed
Jesse: *"I'm playing at pace = 7, and the bots are still moving too fast for me to follow."* At 7×
a switching move holds for seven seconds, so that could not be calibration — and it was not. **He was
at 1× the entire time.**
`index.html`'s two doors are `./play.html?lobby` and `./play.html?solitaire`. Arriving through the
splash therefore **replaces** the query string, and `location.search` on the play page is `?lobby` —
so `PACE_OVERRIDE` was null and it fell back to the stored setting of 1. v0.8.0 shipped `?pace=` as
the only way to change speed and the game's own front door destroyed it. Verified rather than
assumed: the queue at pace 7 holds a bot's turn for 32.9s with the bot's district up for 24.5s, so
the mechanism was right and the value never arrived.
Fixed twice over, because one of them is the durable answer:
- **A speed control on the play screen**, beside zoom — `− 1× +`, persisted per viewer, reading
through to the queue on the very next move. `PACE_LEVELS` is `0, 0.5, 1, 2, 3, 5, 7, 10`: off is
the first rung (TODO #18's "a player who has seen it a hundred times will want it off") and the
ladder reaches the speeds people actually reach for. At the top, a six-move switching turn takes a
full minute to watch.
- **The doors now carry `pace` through**, so the URL lever is honest for handing two playtesters
different speeds — the only thing it was ever for. When one is present the control says
`7× (URL)` and disables itself rather than showing buttons that do nothing.
`PACE_LEVELS` lives in `sim/pacing.ts` with `DWELL` and `MAX_PACE`, not in `main.ts` — the whole
tuning surface in one file, and testable, which a constant inside the page entry point is not.
**The committed default is unchanged at 1×.** What it should be is a question for a game played at a
speed that actually took effect.
### "0 today, 2 in all" — the Day-end dialog contradicted itself Jesse, at the end of a Day 1 with
two collisions in it: *"It shows a total of two collisions, but zero today. Since we just finished day
one, that does seem to be a contradiction."* Unrelated to v0.8.0 — this has been wrong since the
dialog was built for Gitea#10, and nobody had played a Day with a collision in it and then read the
summary.
#### One line of ordering
`advance.ts`, at the rollover:
```ts
s.clock.day += 1;
s.collisionsToday = 0;
```
And `noteDayEnd()` fires when `f.day` goes UP — so the dialog reporting the Day that just finished is
drawn from the very frame in which that Day's count was zeroed. It printed the *new* Day's zero beside
a running total that could not possibly agree with it. Reproduced on four of five seeds before
touching anything: Day 1 ended with `today=3 total=3`, and the dialog read `today=0 total=3`.
**Not derivable on the client, which is why the fix is in the engine.** A Day turns over inside the
phases that run themselves, so in multiplayer the push announcing the new Day is the same push that
carries the reset — a client may never see the ended Day's final count to remember it. So
`collisionsPrevDay` is captured in state at the rollover, immediately before the reset, and rides on
the frame like the other two counts.
#### And "today" was the wrong word anyway
Even with the right number, a dialog headed "Day 1 has ended" should not say "today" — by then
"today" is Day 2. It now names the Day: **"Collisions: 2 on Day 1, 2 in all."** The end-of-game
results screen passes no Day and keeps "today", where the Day has not turned over and the word is
accurate.
`test/redaction.test.ts`'s allow-list did its job on the way through: adding a public property failed
the suite until it was declared out loud.
---
## 0.8.0.1 — 2026-09-09 ## 0.8.0.1 — 2026-09-09
**Bot play was way too fast.** v0.8.0 was installed on `phoenix.local` and played within the hour; **Bot play was way too fast.** v0.8.0 was installed on the test server and played within the hour;
Jesse: *"I briefly saw that it was the bot's office area then their turn was done and it pointed back Jesse: *"I briefly saw that it was the bot's office area then their turn was done and it pointed back
to my office area"*, and the countdown row appeared "very briefly". Everything else looked right — to my office area"*, and the countdown row appeared "very briefly". Everything else looked right —
the bots were visibly doing things — so this is calibration and one real bug, not a redesign. the bots were visibly doing things — so this is calibration and one real bug, not a redesign.
+4 -2
View File
@@ -56,7 +56,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 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 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 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 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 the game is dealt**, along with the opening hand and where an Extra may start, so the economy can be
@@ -102,7 +103,8 @@ separate thing: it assembles the static SITE into `dist/`.)
```sh ```sh
npm install 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 npm run typecheck # tsc --noEmit
``` ```
+271 -10
View File
@@ -81,7 +81,7 @@ Not items. Things that are true of every change, and that have gone wrong when s
5. **Replays and saved games** — #14 #47 #48 #49 #50 #51 #52 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
7. **Play balance** — #61 #62 #63 #64 #67 #68 #69 #70 #71 #72 #73 #66 #65 #74 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 9. **Code health and housekeeping** — #46 #45 #84 #87
10. **Documentation and assets** — #15a #86 #88 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 **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 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 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 specific paths below have not been exercised at a table.
0.7.9 series, before 0.8.0 starts** — that is the moment to close these, not a separate errand.
**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 - [ ] **#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, 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 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 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 - [ ] **#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 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 (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 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 - [ ] **#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 — the dealt game matches what was chosen. It took three attempts to become reachable at all —
@@ -368,21 +467,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 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.** 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 - [ ] **#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 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. is plant a flag ON PURPOSE to buy a Stage for switching, which needs it to know it wants time.
See **Reference · #41**. See **Reference · #41**.
- [ ] **#57** — The bot's priorities are not the problem — measured across ten heuristic variations. - [ ] **#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 **Read this before tuning weights**; it is the argument that the ceiling is elsewhere. **Part of
**Reference · #57**. "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. - [ ] **#59** — The run-around is out of reach of any bot, and the deck is why — measured five ways.
See **Reference · #59**. 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 - [ ] **#54** — The bot cannot spot a car at a stub industry, and the cut-ordering rules made that
visible. See **Reference · #54**. visible. See **Reference · #54**.
@@ -1424,6 +1539,12 @@ measuring deck luck rather than reachability, and its comment now says so.
#### #57 — THE BOT'S PRIORITIES ARE NOT THE PROBLEM — measured. #### #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 **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 over 400+ seeds. Every reordering of what the bot prefers came out inside the noise; the only
@@ -1449,6 +1570,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… #### #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.** **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 "Teach the bot to plan across turns" was tried properly and does not work. Every attempt is
@@ -1490,6 +1626,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 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. 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… #### #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.** **THE BOT CANNOT SPOT A CAR AT A STUB INDUSTRY, and the cut-ordering rules made that visible.**
@@ -1511,6 +1649,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… #### #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 **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 now stub-only and the bot places 2.23 a game (was 3.84), in districts averaging under two rows
@@ -1558,6 +1702,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 have looked like nothing. They are nearly free to re-run now and at least one may have been
discarded wrongly. 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 ### Code health and housekeeping
#### #46 — tsc --noUnusedLocals finds 29 unused declarations across 14 files, and… #### #46 — tsc --noUnusedLocals finds 29 unused declarations across 14 files, and…
@@ -1801,6 +2053,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 Closed items, kept because several are the only record of a ruling or a lesson. Newest first within
each group. 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 ### 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; Closed items, newest first. Kept because several of them are the only record of a ruling or a lesson;
+7 -6
View File
@@ -361,14 +361,15 @@ Introduce dedicated allow-listed types. Do not derive them with `Omit<Frame, ...
> **Read those two, not this**, when building steps 2-7. The differences that matter: > **Read those two, not this**, when building steps 2-7. The differences that matter:
> >
> - **The shape is FLAT, not grouped.** There is no `clock`, `config`, `scoring` or `deckCounts` > - **The shape is FLAT, not grouped.** There is no `clock`, `config`, `scoring` or `deckCounts`
> object. Their contents sit at the top level. **All 37 properties, which is the same list as > object. Their contents sit at the top level. **All 38 properties, which is the same list as
> `test/redaction.test.ts`'s allow-list** — `day`, `stage`, `clock` (a time string), `phase`, > `test/redaction.test.ts`'s allow-list** — `day`, `stage`, `clock` (a time string), `phase`,
> `phaseKey`, `actor`, `superintendent`, `deck`, `departments`, `departmentsWhat`, > `phaseKey`, `actor`, `superintendent`, `deck`, `departments`, `departmentsWhat`,
> `departmentDepth`, `salvage`, `yards`, `timetable`, `timetableWhat`, `houseRules`, `mode`, > `departmentDepth`, `salvage`, `yards`, `timetable`, `timetableWhat`, `houseRules`, `mode`,
> `optionalRules`, `days`, `minCombinedRevenue`, `maxCollisionsPerDay`, `maxCollisionsTotal`, > `optionalRules`, `days`, `minCombinedRevenue`, `maxCollisionsPerDay`, `maxCollisionsTotal`,
> `collisionsToday`, `collisionsTotal`, `status`, `outcome`, `extraDays`, `extensionVotes`, > `collisionsToday`, `collisionsPrevDay`, `collisionsTotal`, `status`, `outcome`, `extraDays`,
> `extensionVotes`,
> `official`, `tally`, `players`, `openingRolls`, `trains`, `crewTrays`, `queued`, `division`, > `official`, `tally`, `players`, `openingRolls`, `trains`, `crewTrays`, `queued`, `division`,
> `districts`. The first 35 come from `projectSharedTable`; `division` and `districts` are added > `districts`. The first 36 come from `projectSharedTable`; `division` and `districts` are added
> by `PublicFrame` itself. > by `PublicFrame` itself.
> - **`protocolVersion` was NOT built** and exists nowhere in the repo. **Decided 2026-09-09: add it > - **`protocolVersion` was NOT built** and exists nowhere in the repo. **Decided 2026-09-09: add it
> in step 2.** `display.json` carries its own `schemaVersion`, and the SSE wire format is a second, > in step 2.** `display.json` carries its own `schemaVersion`, and the SSE wire format is a second,
@@ -887,7 +888,7 @@ Cover:
## Step 5 — Minimal visual-only Jitsi engine ## Step 5 — Minimal visual-only Jitsi engine
> **v0.9.0 — DEFERRED (2026-09-09).** Steps 5-7 are the Jitsi publisher and are explicitly held > **v0.9.0 — DEFERRED (2026-09-09).** Steps 5-7 are the Jitsi publisher and are explicitly held
> off until 0.8.0 ships and Chromium has been measured on `phoenix.local`. Nothing below has been > off until 0.8.0 ships and Chromium has been measured on the target hardware. Nothing below has been
> re-verified against the current code; it was written 2026-08-27. Two things will have moved by > re-verified against the current code; it was written 2026-08-27. Two things will have moved by
> the time it is picked up: the control channel is **SSE + POST, not a WebSocket** (§ THE RELEASE > the time it is picked up: the control channel is **SSE + POST, not a WebSocket** (§ THE RELEASE
> SPLIT), and adding Chromium takes the `.s9pk` from 63 MB to several hundred against a package > SPLIT), and adding Chromium takes the `.s9pk` from 63 MB to several hundred against a package
@@ -1027,7 +1028,7 @@ Port/adapt the sibling repository’s proven tests for:
## Step 6 — Chromium publisher supervisor ## Step 6 — Chromium publisher supervisor
> **v0.9.0 — DEFERRED (2026-09-09).** Steps 5-7 are the Jitsi publisher and are explicitly held > **v0.9.0 — DEFERRED (2026-09-09).** Steps 5-7 are the Jitsi publisher and are explicitly held
> off until 0.8.0 ships and Chromium has been measured on `phoenix.local`. Nothing below has been > off until 0.8.0 ships and Chromium has been measured on the target hardware. Nothing below has been
> re-verified against the current code; it was written 2026-08-27. Two things will have moved by > re-verified against the current code; it was written 2026-08-27. Two things will have moved by
> the time it is picked up: the control channel is **SSE + POST, not a WebSocket** (§ THE RELEASE > the time it is picked up: the control channel is **SSE + POST, not a WebSocket** (§ THE RELEASE
> SPLIT), and adding Chromium takes the `.s9pk` from 63 MB to several hundred against a package > SPLIT), and adding Chromium takes the `.s9pk` from 63 MB to several hundred against a package
@@ -1160,7 +1161,7 @@ Use fake child processes and fake control sockets to test:
## Step 7 — Configuration, lifecycle, packaging, and observability ## Step 7 — Configuration, lifecycle, packaging, and observability
> **v0.9.0 — DEFERRED (2026-09-09).** Steps 5-7 are the Jitsi publisher and are explicitly held > **v0.9.0 — DEFERRED (2026-09-09).** Steps 5-7 are the Jitsi publisher and are explicitly held
> off until 0.8.0 ships and Chromium has been measured on `phoenix.local`. Nothing below has been > off until 0.8.0 ships and Chromium has been measured on the target hardware. Nothing below has been
> re-verified against the current code; it was written 2026-08-27. Two things will have moved by > re-verified against the current code; it was written 2026-08-27. Two things will have moved by
> the time it is picked up: the control channel is **SSE + POST, not a WebSocket** (§ THE RELEASE > the time it is picked up: the control channel is **SSE + POST, not a WebSocket** (§ THE RELEASE
> SPLIT), and adding Chromium takes the `.s9pk` from 63 MB to several hundred against a package > SPLIT), and adding Chromium takes the `.s9pk` from 63 MB to several hundred against a package
+4 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "station-master", "name": "station-master",
"version": "0.8.0.1", "version": "0.8.0.10",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Station Master — a railroad operations game", "description": "Station Master — a railroad operations game",
@@ -10,7 +10,9 @@
"scripts": { "scripts": {
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"pretest": "tsc --noEmit && node scripts/build-web.ts", "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", "build:web": "node scripts/build-web.ts",
"serve:web": "node scripts/build-web.ts && npx --yes http-server dist -p 8080 -c-1", "serve:web": "node scripts/build-web.ts && npx --yes http-server dist -p 8080 -c-1",
"deploy:web": "node scripts/deploy-web.ts", "deploy:web": "node scripts/deploy-web.ts",
+36 -1
View File
@@ -94,6 +94,18 @@ function nodeIndexOfOffice(s: GameState, seat: SeatIndex): number {
const step = (d: Direction): number => (d === 'east' ? 1 : -1); const step = (d: Direction): number => (d === 'east' ? 1 : -1);
/** The Division node a train is standing on — its Office, its Mainline card, or its Division Point. */
function nodeIndexOfTray(s: GameState, tray: CrewTray): number | null {
const at = tray.position;
if (at.at === 'grid') return nodeIndexOfOffice(s, at.seat);
if (at.at === 'mainline') return at.index;
if (at.at === 'divisionPoint') {
const side = at.side;
return s.division.nodes.findIndex((n) => n.kind === 'divisionPoint' && n.side === side);
}
return null;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// advance // advance
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -522,8 +534,11 @@ type MoveOutcome = 'moved' | 'held' | 'needsClearance';
* *
* This is reachable purely through switching. A train arrives made up, and only comes apart because * 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. * 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; const n = tray.consist.length;
if (n === 0) return null; if (n === 0) return null;
const pulling = tray.engineAt === 0; const pulling = tray.engineAt === 0;
@@ -1064,10 +1079,27 @@ function evaluateClearance(
* constrained. Each Office upgrade to a Control Point splits one in two and buys capacity. * constrained. Each Office upgrade to a Control Point splits one in two and buys capacity.
*/ */
const subdivision = subdivisions(s).find((group) => group.includes(targetIndex)) ?? [targetIndex]; const subdivision = subdivisions(s).find((group) => group.includes(targetIndex)) ?? [targetIndex];
/**
* ONLY WHAT IS AHEAD (Gitea#26). §8.1 asks about a train the considered train would FOLLOW, and one
* moving TOWARDS it — both of which are ahead of it. A Subdivision runs the length of every Whistle
* Post between two Control Points, so it can hold a train BEHIND the one departing: in the reported
* game X15 highballed west from an Office while X18, also westbound, was still crossing the card to its
* east. Counting X18 put a meaningless ruling to the Superintendent; holding X15 kept the Whistle Post's
* one A/D track full, and X18 arrived into it and was destroyed. A train behind and moving away is no
* threat at all.
*
* "Behind" is strictly behind the card the departing train stands on. A train on that same card is still
* counted, exactly as before: which of two trains sharing a card is in front is `entryConflict`'s region
* question, and this is not the place to answer it.
*/
const from = nodeIndexOfTray(s, tray);
const behind = (onCard: number): boolean =>
from !== null && from >= 0 && (tray.direction === 'east' ? onCard < from : onCard > from);
const occupants: { tray: TrayId; onCard: number }[] = []; const occupants: { tray: TrayId; onCard: number }[] = [];
for (const i of subdivision) { for (const i of subdivision) {
const n = s.division.nodes[i]; const n = s.division.nodes[i];
if (!n || n.kind !== 'mainline') continue; if (!n || n.kind !== 'mainline') continue;
if (behind(i)) continue;
for (const t of n.transits) if (t.tray) occupants.push({ tray: t.tray, onCard: i }); for (const t of n.transits) if (t.tray) occupants.push({ tray: t.tray, onCard: i });
} }
@@ -1638,6 +1670,9 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
s.clock.day += 1; s.clock.day += 1;
s.clock.stage = 1; s.clock.stage = 1;
// Captured BEFORE the reset: the Day-end dialog reports the Day that just finished, and it is
// drawn from the frame this rollover produces. See `collisionsPrevDay` in `state.ts`.
s.collisionsPrevDay = s.collisionsToday;
s.collisionsToday = 0; s.collisionsToday = 0;
events.push({ type: 'stageBegan', day: s.clock.day, stage: 1 }); events.push({ type: 'stageBegan', day: s.clock.day, stage: 1 });
rotateSeats(s, events); rotateSeats(s, events);
+109 -13
View File
@@ -1508,17 +1508,48 @@ export function selectDestination(
return chosen ?? atTo[0]; 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( function destinationsFor(
s: GameState, s: GameState,
player: PlayerIndex, player: PlayerIndex,
trayId: TrayId, trayId: TrayId,
from: GridCoord, from: GridCoord,
reverse: boolean, 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 tray = s.trays.get(trayId)!;
const facing = facingPort(s, trayId); const facing = facingPort(s, trayId);
const exit: Port = reverse ? reversePort(s, player, from, facing) : facing; const exit: Port = reverse ? reversePort(s, player, from, facing) : facing;
return reachableDestinations( const routes = reachableDestinations(
{ {
area: areaOf(s, player), area: areaOf(s, player),
occupancy: occupancyFor(s, player, trayId), occupancy: occupancyFor(s, player, trayId),
@@ -1528,6 +1559,8 @@ function destinationsFor(
from, from,
exit, exit,
); );
cache?.set(key, routes);
return routes;
} }
/** /**
@@ -1856,7 +1889,15 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
? REALIGNMENTS.find((r) => r.from === node.card)?.to ? REALIGNMENTS.find((r) => r.from === node.card)?.to
: undefined; : undefined;
return [ return [
{ type: 'mainlineModified', player, cardId: i.cardId, node: i.node, key, ...(became ? { became } : {}) }, {
type: 'mainlineModified',
player,
cardId: i.cardId,
node: i.node,
key,
...(node?.kind === 'mainline' ? { from: node.card } : {}),
...(became ? { became } : {}),
},
]; ];
} }
@@ -2242,7 +2283,8 @@ export function reduce(s: GameState, e: GameEvent): void {
// Everything swept comes back as ONE pile, then §4.6-4.7's opening is re-run: three cards // Everything swept comes back as ONE pile, then §4.6-4.7's opening is re-run: three cards
// turned face up as the Departments, the rest face down as the Home Office deck. The // turned face up as the Departments, the rest face down as the Home Office deck. The
// Departments start one deep again, exactly as at setup. // Departments start one deep again, exactly as at setup.
s.decks.salvageYard = []; // The spent trains stay where they are; everything else in the Yard has just been swept up.
s.decks.salvageYard = s.decks.salvageYard.filter((id) => isSpentTimetabledTrain(s, id));
s.decks.departments = [[], [], []]; s.decks.departments = [[], [], []];
const order = [...e.order]; const order = [...e.order];
for (const pile of s.decks.departments) { for (const pile of s.decks.departments) {
@@ -2473,7 +2515,15 @@ export function reduce(s: GameState, e: GameEvent): void {
case 'trainScheduled': case 'trainScheduled':
s.timetable[e.slot] = e.trainNumber; s.timetable[e.slot] = e.trainNumber;
s.rngState = e.rngState; s.rngState = e.rngState;
s.decks.salvageYard.push(`train-${e.trainNumber}`); /**
* THE CARD IS ALREADY IN THE SALVAGE YARD — `cardPlayed` put it there, by its real id.
*
* This used to push a second, SYNTHETIC `train-<number>` beside it, so scheduling four trains
* left eight entries in a pile holding four cards. Nothing ever read that id: it inflated the
* pile's depth, it displayed as "a card" because no such card exists, and
* `reshuffleIfDepleted` would have swept it into the draw deck to be drawn as an id with
* nothing behind it. Removed 2026-09-10 (Gitea#23).
*/
break; break;
case 'carPlacedOnTrain': { case 'carPlacedOnTrain': {
@@ -2693,7 +2743,8 @@ export function reduce(s: GameState, e: GameEvent): void {
* index is out of range, which `check` reports rather than silently defaulting — a wrong * index is out of range, which `check` reports rather than silently defaulting — a wrong
* orientation is a different card, not a detail. * 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 }, kind: { kind: string; geometry?: string; facility?: string; hand?: string },
variant: number | undefined, variant: number | undefined,
): TrackCard | null { ): TrackCard | null {
@@ -2930,9 +2981,34 @@ function spendCard(s: GameState, player: PlayerIndex, cardId: CardId): void {
* that has genuinely used every card ends on `DECK_EMPTY` rather than reshuffling an empty pile. * that has genuinely used every card ends on `DECK_EMPTY` rather than reshuffling an empty pile.
* Cards played onto the board are NOT recovered: they are on the table, which is where they belong. * Cards played onto the board are NOT recovered: they are on the table, which is where they belong.
*/ */
/**
* §6.2, AND THE RULING THAT SETTLES IT — Jesse, 2026-09-10 (Gitea#23).
*
* "Once you've played a regularly scheduled train and it's in the salvage deck, that train is
* already on the timetable. It does not make sense to put that back into a reshuffled home deck to
* get played again. By contrast, a regularly scheduled train that's in a discard pile could
* potentially get reused later, and so should have that capability. Extras run one time and then
* they're done — if they are in the Salvage deck, they should get shuffled back in so that they
* could get run again."
*
* So the test is WHERE the card is, not only what it is. A timetabled train in the SALVAGE YARD was
* played: its number is on the timetable and cannot be scheduled twice, so the card is spent and
* stays out. The same card sitting in a DEPARTMENT was discarded, never played, and its slot is
* still open — so it comes back with everything else. An Extra is a single run rather than a
* standing slot, so a played one is free to be run again.
*/
function isSpentTimetabledTrain(s: GameState, id: CardId): boolean {
return s.cards.get(id)?.kind.kind === 'timetabledTrain';
}
function reshuffleIfDepleted(s: GameState, taking: number): GameEvent | null { function reshuffleIfDepleted(s: GameState, taking: number): GameEvent | null {
if (s.decks.homeOffice.length > taking) return null; if (s.decks.homeOffice.length > taking) return null;
const collected = [...s.decks.salvageYard, ...s.decks.departments.flat()]; const collected = [
// The Salvage Yard, less the trains whose slots are already filled — see above.
...s.decks.salvageYard.filter((id) => !isSpentTimetabledTrain(s, id)),
// Every Department in full: a discarded train was never played, so it is still runnable.
...s.decks.departments.flat(),
];
if (collected.length === 0) return null; if (collected.length === 0) return null;
const rng = createRng(s.rngState); const rng = createRng(s.rngState);
return { type: 'deckReshuffled', order: rng.shuffle(collected), rngState: rng.getState() }; return { type: 'deckReshuffled', order: rng.shuffle(collected), rngState: rng.getState() };
@@ -3059,7 +3135,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 * §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. * 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.row !== area.runningRow) return;
if (placed.col <= area.limitsWest.col) { if (placed.col <= area.limitsWest.col) {
@@ -3253,16 +3330,35 @@ function limitsCard(): TrackCard {
// Public entry point // Public entry point
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function applyIntent(s: GameState, player: PlayerIndex, i: Intent): ApplyResult { /**
const code = check(s, player, i); * THE FIRST HALF OF `applyIntent`: decide, without changing anything.
if (code) return { ok: false, code, message: `${i.type} rejected: ${code}` }; *
* `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); 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` // 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 why it cannot simply live inside `reduce`.
for (const e of events) tallyEvent(s, e); 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 }; export { isOperationalRail, destinationsFor };
+7 -1
View File
@@ -87,7 +87,13 @@ export type GameEvent =
| { type: 'deckReshuffled'; order: CardId[]; rngState: number } | { type: 'deckReshuffled'; order: CardId[]; rngState: number }
/** `variant` is the chosen orientation (Gap 11); it must be replayable, so it rides the event. */ /** `variant` is the chosen orientation (Gap 11); it must be replayable, so it rides the event. */
| { type: 'cardPlayed'; player: PlayerIndex; cardId: CardId; placement?: GridCoord; variant?: number } | { type: 'cardPlayed'; player: PlayerIndex; cardId: CardId; placement?: GridCoord; variant?: number }
| { type: 'mainlineModified'; player: PlayerIndex; cardId: CardId; node: number; key: string; became?: string } /**
* `from` is the card's kind BEFORE the change, carried so the log can say what was realigned
* rather than only what it turned into (playtest, 2026-09-15: "it should state that the mainline
* card 3 curves was converted to plains"). Events are derived by replaying a save, never stored,
* so widening one strands nothing on disk.
*/
| { type: 'mainlineModified'; player: PlayerIndex; cardId: CardId; node: number; key: string; from?: string; became?: string }
/** §Q (Gitea#19) — a flag planted on one side of a district's Limits. */ /** §Q (Gitea#19) — a flag planted on one side of a district's Limits. */
| { type: 'redFlagsSet'; player: PlayerIndex; cardId: CardId; seat: SeatIndex; side: Direction } | { type: 'redFlagsSet'; player: PlayerIndex; cardId: CardId; seat: SeatIndex; side: Direction }
/** §Q (Gitea#19) — the flag stopped a train and came down with it. One card, one train. */ /** §Q (Gitea#19) — the flag stopped a train and came down with it. One card, one train. */
+76 -58
View File
@@ -14,7 +14,7 @@
import type { CarType, Hand, TrackGeometry } from './content.ts'; import type { CarType, Hand, TrackGeometry } from './content.ts';
import { enhancementRule, mainlineProfile } 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 { Intent } from './intents.ts';
import type { GameState, GridCoord, PlayerIndex } from './state.ts'; import type { GameState, GridCoord, PlayerIndex } from './state.ts';
import { coordKey, seatOf } from './state.ts'; import { coordKey, seatOf } from './state.ts';
@@ -53,7 +53,80 @@ const CAR_TYPES: readonly CarType[] = ['coach', 'boxcar', 'reefer', 'hopper', 't
/** Every intent `player` may legally submit right now. */ /** Every intent `player` may legally submit right now. */
export function legalActions(s: GameState, player: PlayerIndex): Intent[] { 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.
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' });
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 { export function isLegal(s: GameState, player: PlayerIndex, i: Intent): boolean {
@@ -138,62 +211,7 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
const area = areaOf(s, player); const area = areaOf(s, player);
// -- switch (§6.1) // -- switch (§6.1)
for (const [trayId, tray] of s.trays) { out.push(...switchCandidates(s, player));
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' });
// -- draw (§6.2) // -- draw (§6.2)
out.push({ type: 'draw.fromHomeOffice' }); out.push({ type: 'draw.fromHomeOffice' });
+1
View File
@@ -431,6 +431,7 @@ export function createGame(opts: SetupOptions): GameState {
turns: freshTurns(playerCount, MOVES_PER_LOCAL_OPS), turns: freshTurns(playerCount, MOVES_PER_LOCAL_OPS),
movedThisPhase: new Set(), movedThisPhase: new Set(),
collisionsToday: 0, collisionsToday: 0,
collisionsPrevDay: 0,
collisionsTotal: 0, collisionsTotal: 0,
status: 'active', status: 'active',
outcome: null, outcome: null,
+14
View File
@@ -1119,6 +1119,20 @@ export type GameState = {
movedThisPhase: Set<TrayId>; movedThisPhase: Set<TrayId>;
/** §3.4 — resets at the start of each Day; checked against `config.maxCollisionsPerDay`. */ /** §3.4 — resets at the start of each Day; checked against `config.maxCollisionsPerDay`. */
collisionsToday: number; collisionsToday: number;
/**
* What `collisionsToday` held for the Day that just ENDED — captured at the rollover, immediately
* before the reset.
*
* The Day-end dialog exists to report the Day that finished, and it is drawn from the frame AFTER
* the rollover, because that is the frame whose `day` went up. So it read `collisionsToday` as 0 no
* matter what had happened: Jesse, 2026-09-09, at the end of a Day 1 with two collisions in it —
* "it shows a total of two collisions, but zero today ... that does seem to be a contradiction".
*
* NOT DERIVABLE ON THE CLIENT. A Day turns over inside the phases that run themselves, so in
* multiplayer the push that reports the new Day is the same push that reports the reset — a client
* may never see the ended Day's final count to remember it.
*/
collisionsPrevDay: number;
/** §3.4 — never reset; checked against `config.maxCollisionsTotal`. */ /** §3.4 — never reset; checked against `config.maxCollisionsTotal`. */
collisionsTotal: number; collisionsTotal: number;
/** /**
+34 -13
View File
@@ -377,7 +377,7 @@ export function reachableDestinations(
start: GridCoord, start: GridCoord,
initialExit: Port, initialExit: Port,
): MoveDestination[] { ): MoveDestination[] {
return exploreMoves(ctx, start, initialExit).destinations; return exploreMoves(ctx, start, initialExit, false).destinations;
} }
/** /**
@@ -434,12 +434,19 @@ export function exploreMoves(
ctx: MoveContext, ctx: MoveContext,
start: GridCoord, start: GridCoord,
initialExit: Port, 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[] } { ): { destinations: MoveDestination[]; blocked: MoveBlock[] } {
const { area, occupancy } = ctx; const { area, occupancy } = ctx;
const results: MoveDestination[] = []; const results: MoveDestination[] = [];
const blocked: MoveBlock[] = []; const blocked: MoveBlock[] = [];
const noted = new Set<string>(); const noted = new Set<string>();
const block = (coord: GridCoord, kind: MoveBlockKind, why: string): void => { const block = (coord: GridCoord, kind: MoveBlockKind, why: string): void => {
if (!collectBlocks) return;
const k = coordKey(coord); const k = coordKey(coord);
if (noted.has(k)) return; if (noted.has(k)) return;
noted.add(k); noted.add(k);
@@ -459,10 +466,25 @@ export function exploreMoves(
couples: RollingStock[]; couples: RollingStock[];
/** Coord key each entry in `couples` came off, aligned by index — see `routeOutcomeKey`. */ /** Coord key each entry in `couples` came off, aligned by index — see `routeOutcomeKey`. */
origins: string[]; 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 // 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: [], path: [],
couples: ownCut, couples: ownCut,
origins: ownCut.map(() => startKey), origins: ownCut.map(() => startKey),
visited: new Set([startKey]),
}, },
]; ];
let enumerated = 1; let enumerated = 1;
while (queue.length > 0) { // FIFO by index rather than `shift()`, which re-packs the array on every pop. Same order.
const node = queue.shift()!; let head = 0;
while (head < queue.length) {
const node = queue[head++]!;
const card = cardAt(area, node.coord); const card = cardAt(area, node.coord);
if (!card) continue; if (!card) continue;
@@ -592,17 +615,15 @@ export function exploreMoves(
// direction; it never says without repeating ground, but a train cannot occupy the same // 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 // track twice at once either). Per-path, not global — a DIFFERENT route may legitimately
// pass through a card this one already used. // pass through a card this one already used.
const toKey = coordKey(to); if (onRoute(node, to)) continue;
if (node.visited.has(toKey)) continue;
if (enumerated >= MAX_ENUMERATED_FRONTIER) break; if (enumerated >= MAX_ENUMERATED_FRONTIER) break;
enumerated++; enumerated++;
const step: MoveStep = { coord: node.coord, entry: node.entry, exit }; const step: MoveStep = { coord: node.coord, entry: node.entry, exit };
const visited = new Set(node.visited); queue.push({ coord: to, entry: opposite(exit), path: [...node.path, step], couples, origins });
visited.add(toKey);
queue.push({ coord: to, entry: opposite(exit), path: [...node.path, step], couples, origins, visited });
} }
} }
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 // 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. // from a bad angle first and a good one later.
const reached = new Set(results.map((r) => coordKey(r.coord))); const reached = new Set(results.map((r) => coordKey(r.coord)));
+21
View File
@@ -656,6 +656,27 @@ export function startServer(opts: ServerOptions): void {
return; return;
} }
/**
* THIS SEAT'S OWN GAME, AS A SAVE (playtest, 2026-09-15: "most of the time, I want to go ahead and
* just save it as a JSON file in my Downloads folder").
*
* The administrative export at `/api/games/<id>/save` is gated on the admin secret, which a player
* does not have and should not need: a save is the seed and the moves, and every one of those moves
* is already on this player's screen. So the seat's own session token is the gate, exactly as it is
* for `/api/stream` and `/api/intent` — it proves which game and which chair, and nothing else is
* disclosed. The page turns the JSON into a file (`main.ts`'s `downloadSave`).
*/
if (url.pathname === '/api/save' && req.method === 'GET') {
const ps = sessions.get(url.searchParams.get('token') ?? '');
const session = ps ? games.get(ps.gameId) : undefined;
if (!ps || !session) {
sendJson(res, 404, { error: 'no such game' });
return;
}
sendJson(res, 200, { gameId: ps.gameId, save: session.exportSave() });
return;
}
if (url.pathname === '/api/stream' && req.method === 'GET') { if (url.pathname === '/api/stream' && req.method === 'GET') {
const token = url.searchParams.get('token') ?? ''; const token = url.searchParams.get('token') ?? '';
const ps = sessions.get(token); const ps = sessions.get(token);
+24 -2
View File
@@ -42,6 +42,12 @@ export type DivisionRoster = {
actor: number | null; actor: number | null;
/** The player this map is being drawn for. */ /** The player this map is being drawn for. */
viewer: number; viewer: number;
/**
* Division nodes to flash — a Mainline card that has just become a different card (Realignment).
* Playtest, 2026-09-15: the log said a card had been converted and the map said nothing, so the one
* play that changes the Division itself was invisible on the map of it.
*/
flash?: readonly number[];
}; };
export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | null): string { export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | null): string {
@@ -134,6 +140,8 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
}[]; }[];
cap: number | null; cap: number | null;
tip: string; tip: string;
/** Set on a card that has just changed under the players' feet — drawn with a brief pulse. */
flash?: boolean;
/** Which SEAT's district this cell belongs to, or null for Mainline and Division Points. */ /** Which SEAT's district this cell belongs to, or null for Mainline and Division Points. */
seat: number | null; seat: number | null;
/** Set on an Office cell when a roster was supplied: whose district this is. */ /** Set on an Office cell when a roster was supplied: whose district this is. */
@@ -168,7 +176,11 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
cells.push({ ...c, x: 0, y: 0 }); cells.push({ ...c, x: 0, y: 0 });
}; };
// The node's own index, so a cell can be matched against `roster.flash`. `continue` below skips the
// rest of the body, never this.
let nodeIndex = -1;
for (const n of nodes) { for (const n of nodes) {
nodeIndex++;
if (n.kind === 'office') { if (n.kind === 'office') {
const cap = n.capacity; const cap = n.capacity;
const ad = n.trains.flat(); const ad = n.trains.flat();
@@ -261,6 +273,7 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
push({ push({
kind: dp ? 'dp' : 'ml', kind: dp ? 'dp' : 'ml',
label: n.label, label: n.label,
...(roster?.flash?.includes(nodeIndex) ? { flash: true } : {}),
sub: n.capacity === null sub: n.capacity === null
? 'no limit — trains queue' ? 'no limit — trains queue'
: [free, inYard.length > 0 ? `${inYard.length} in the yard` : ''].filter(Boolean).join(' · '), : [free, inYard.length > 0 ? `${inYard.length} in the yard` : ''].filter(Boolean).join(' · '),
@@ -356,7 +369,7 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
cells.forEach((c) => { cells.forEach((c) => {
const full = c.cap !== null && c.trains.length >= c.cap; const full = c.cap !== null && c.trains.length >= c.cap;
out += `<g class="bs-dcell bs-d${c.kind}${full ? ' bs-full' : ''}" data-tip="${esc(c.tip)}">`; out += `<g class="bs-dcell bs-d${c.kind}${full ? ' bs-full' : ''}${c.flash ? ' bs-changed' : ''}" data-tip="${esc(c.tip)}">`;
out += `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${CH}" rx="5"/>`; out += `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${CH}" rx="5"/>`;
/** /**
* WHOSE IS IT, IS IT THEIR MOVE, AND IS IT MINE — answered by colour and one suffix rather * WHOSE IS IT, IS IT THEIR MOVE, AND IS IT MINE — answered by colour and one suffix rather
@@ -1248,6 +1261,12 @@ export const BOARD_CSS = `
and leave at the other, and a seated layout must not be read as a ring. */ and leave at the other, and a seated layout must not be read as a ring. */
.bs-stop line{stroke:#e0a060;stroke-width:2.6;stroke-linecap:round} .bs-stop line{stroke:#e0a060;stroke-width:2.6;stroke-linecap:round}
.bs-end{fill:#e0a060;font:10px ui-monospace,monospace;letter-spacing:.03em} .bs-end{fill:#e0a060;font:10px ui-monospace,monospace;letter-spacing:.03em}
/* 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). Drawn faint: they are the ruler the /* 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. */ 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} .bs-region{stroke:#4a5361;stroke-width:1.2;stroke-dasharray:3 3}
@@ -1329,7 +1348,10 @@ export const BOARD_CSS = `
.bs-arrow{fill:#5f6b7a;font:10px ui-monospace,monospace} .bs-arrow{fill:#5f6b7a;font:10px ui-monospace,monospace}
.bs-cn{fill:#e6e9ee;font:600 11px ui-monospace,monospace} .bs-cn{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
.bs-coord{fill:#5f6b7a;font:9px ui-monospace,monospace} .bs-coord{fill:#5f6b7a;font:9px ui-monospace,monospace}
.bs-name{fill:#e6e9ee;font:600 11px ui-monospace,monospace} /* stroke:none (Gitea#24). A name takes the class \`bs-turn\` while it is that player's move, and \`.bs-turn\` is
also the turn ARROW's rule, which strokes its shape 2.4px grey. Declared after it, this keeps that
outline off the letters, which it smeared into an unreadable blur. */
.bs-name{fill:#e6e9ee;stroke:none;font:600 11px ui-monospace,monospace}
.bs-name.bs-you{fill:#5aa9e6} .bs-name.bs-you{fill:#5aa9e6}
/* Their move — wins over .bs-you when both apply, because whose turn it is changes every few /* Their move — wins over .bs-you when both apply, because whose turn it is changes every few
seconds and which railroad is yours never does. seconds and which railroad is yours never does.
+238 -16
View File
@@ -22,6 +22,9 @@
import { import {
applyIntent, applyIntent,
areaOf, areaOf,
extendLimitsIfNeeded,
isLockedOut,
protoCard,
canAdvanceLoad, canAdvanceLoad,
destinationsFor, destinationsFor,
facilityCarTypes, facilityCarTypes,
@@ -29,14 +32,15 @@ import {
ownCutFor, ownCutFor,
} from '../engine/apply.ts'; } from '../engine/apply.ts';
import { MAX_CONSIST, nextOfficeTier, officeProfile } from '../engine/content.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 { GameEvent } from '../engine/events.ts';
import type { Intent } from '../engine/intents.ts'; import type { Intent } from '../engine/intents.ts';
import { legalActions } from '../engine/legal.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 type { Port } from '../engine/track.ts';
import { actingPlayer, coordKey, turnOf } from '../engine/state.ts'; import { actingPlayer, coordKey, turnOf } from '../engine/state.ts';
import type { Facility, GameState, GridCoord, OfficeArea, PlayerIndex, RollingStock, TrackCard } 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 = { export type BotPolicy = {
name: string; 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 * 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 * 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 * 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. * 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 * 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; noTrainCap?: boolean;
/** Draw whenever nothing is urgent, as the bot did before it preferred operating. */ /** Draw whenever nothing is urgent, as the bot did before it preferred operating. */
noOperateFirst?: boolean; 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. */ /** The bot as it plays today. Every knob off. */
export const developerBot: BotPolicy = makeDeveloperBot({}); 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 (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]!); 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 * 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. * 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 best: Intent | null = null;
let bestScore = -Infinity; let bestScore = -Infinity;
for (const i of options) { 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 // 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. // revenue, concentrating on the deepest 2.67, indifferent 2.67.
const top = topOfDepartment(s, i.toSlot); 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 depth = s.decks.departments[i.toSlot]?.length ?? 0;
const score = (top === undefined ? 6 : wanted ? -10 : 2) - Math.min(depth, 4) * 0.5; const score = (top === undefined ? 6 : wanted ? -10 : 2) - Math.min(depth, 4) * 0.5;
if (score > bestScore) { 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. */ /** A face-up card worth spending the draw on rather than gambling on the deck. */
function isWorthTaking(s: GameState, player: PlayerIndex, slot: number): boolean { function isWorthTaking(s: GameState, player: PlayerIndex, slot: number, tweaks: BotTweaks): boolean {
return takingRank(s, player, slot) > 0; 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. * 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 * happened to be scanned first — a coin flip on the card that decides whether the district ever
* becomes a Passenger Facility at all. * 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); const id = topOfDepartment(s, slot);
if (!id) return 0; if (!id) return 0;
const k = s.cards.get(id)?.kind; const k = s.cards.get(id)?.kind;
if (!k) return 0; if (!k) return 0;
if (k.kind === 'office') return nextOfficeTier(areaOf(s, player).tier) === k.tier ? 3 : 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 === 'timetabledTrain' || k.kind === 'extraTrain') {
if (k.kind === 'freightFacility') return 1; 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; return 0;
} }
@@ -741,6 +855,104 @@ function bestFacilityPlay(s: GameState, player: PlayerIndex, options: Intent[]):
return best; 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 { function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null {
const area = areaOf(s, player); const area = areaOf(s, player);
@@ -1213,7 +1425,7 @@ function followThrough(
if (!turnOf(s, player).drawnThisTurn) { if (!turnOf(s, player).drawnThisTurn) {
const piles = options.filter( const piles = options.filter(
(i): i is Extract<Intent, { type: 'draw.fromDepartment' }> => (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 // 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. // 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 // 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 // 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. // 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); 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'); 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); 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 // 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 // district exists. Measured with track absent, the hand held a playable Enhancement on 4,778
// turns and could legally place one on 33. // 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); 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 — // 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', s.cards.get(i.cardId)?.kind.kind !== 'track',
); );
if (placed) return because('develop the district with a card that goes on the board', placed); 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); if (play) return because('play what is in hand', play);
const end = options.find((i) => i.type === 'draw.end'); const end = options.find((i) => i.type === 'draw.end');
if (end) return because('nothing in hand can be played anywhere legal', end); if (end) return because('nothing in hand can be played anywhere legal', end);
return because( return because(
'nothing playable — discard onto the Department whose face-up card is least worth keeping reachable', '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': { 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 * 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. * before any heuristic gets to choose one.
+2 -1
View File
@@ -23,6 +23,7 @@
* Run with: * Run with:
* node src/sim/compare.ts 1600 noTrainCap=1 — what the A/D cap is worth today * 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 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 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 * 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. * 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 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 { export function parseTweaks(args: string[]): BotTweaks {
const tweaks: Record<string, number | boolean> = {}; const tweaks: Record<string, number | boolean> = {};
+18 -12
View File
@@ -15,7 +15,7 @@
* panel cannot drift from the rules. * panel cannot drift from the rules.
*/ */
import { MAX_CONSIST, crewTrayCount } from '../engine/content.ts'; import { MAINLINE_PROFILES, MAX_CONSIST, crewTrayCount } from '../engine/content.ts';
import { adTrackCount, coordKey, seatOf, turnOf } from '../engine/state.ts'; import { adTrackCount, coordKey, seatOf, turnOf } from '../engine/state.ts';
import type { GameState, GridCoord, PlayerIndex, RollingStock, SeatIndex, TrayId } from '../engine/state.ts'; import type { GameState, GridCoord, PlayerIndex, RollingStock, SeatIndex, TrayId } from '../engine/state.ts';
import { areaOf, canAdvanceLoad, canBoard, canDetrain, canStartLoad, facilityCarType, facilityCarTypes, freightRuleSpentHere, isFreight, laborersLeft, movesFor, passengerRefusal, portersLeft } from '../engine/apply.ts'; import { areaOf, canAdvanceLoad, canBoard, canDetrain, canStartLoad, facilityCarType, facilityCarTypes, freightRuleSpentHere, isFreight, laborersLeft, movesFor, passengerRefusal, portersLeft } from '../engine/apply.ts';
@@ -134,7 +134,7 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
case 'actorChanged': case 'actorChanged':
return { return {
tone: 'quiet', tone: 'quiet',
text: e.player === null ? 'No player acts — automatic phase' : `Player ${e.player} to act`, text: e.player === null ? 'No player acts — automatic phase' : 'to act',
}; };
// -- local operations // -- local operations
@@ -211,13 +211,19 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
? `Played ${card(e.cardId)} onto ${at(e.placement)}` ? `Played ${card(e.cardId)} onto ${at(e.placement)}`
: `Played ${card(e.cardId)}`, : `Played ${card(e.cardId)}`,
}; };
case 'mainlineModified': case 'mainlineModified': {
// WHICH CARD, NOT JUST WHICH WAY IT WENT. "Mainline card 3 converted to plains" left the reader
// to remember what card 3 had been (playtest, 2026-09-15), and the card it WAS is the half that
// says what the play was worth.
const kindName = (k: string | undefined): string =>
MAINLINE_PROFILES.find((m) => m.kind === k)?.name ?? k ?? 'that card';
return { return {
tone: 'plain', tone: 'plain',
text: e.became text: e.became
? `Realignment: Mainline card ${e.node} converted to ${e.became}` ? `Realignment: Mainline card ${e.node}, ${kindName(e.from)}, converted to ${kindName(e.became)}`
: `Played ${e.key} on Mainline card ${e.node}`, : `Played ${e.key} on Mainline card ${e.node}${e.from ? ` (${kindName(e.from)})` : ''}`,
}; };
}
case 'redFlagSpent': case 'redFlagSpent':
return { return {
tone: 'good', tone: 'good',
@@ -225,8 +231,8 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
}; };
case 'redFlagRuled': case 'redFlagRuled':
return e.flag return e.flag
? { tone: 'plain', text: `Player ${e.player} flagged the approaching train` } ? { tone: 'plain', text: 'Flagged the approaching train' }
: { tone: 'plain', text: `Player ${e.player} waved the train through` }; : { tone: 'plain', text: 'Waved the train through' };
case 'redFlagsSet': case 'redFlagsSet':
return { return {
tone: 'good', tone: 'good',
@@ -496,13 +502,13 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
? { tone: 'bad', text: `${e.reason.toUpperCase()} · ${e.delta} Revenue (now ${e.total})` } ? { tone: 'bad', text: `${e.reason.toUpperCase()} · ${e.delta} Revenue (now ${e.total})` }
: { tone: 'good', text: `+${e.delta} Revenue (now ${e.total}) — ${e.reason}` }; : { tone: 'good', text: `+${e.delta} Revenue (now ${e.total}) — ${e.reason}` };
case 'phaseEnded': case 'phaseEnded':
return { tone: 'quiet', text: `Player ${e.player} finished ${phaseLabel(e.phase)}` }; return { tone: 'quiet', text: `Finished ${phaseLabel(e.phase)}` };
// -- §3.3, extended play (Gitea#11) // -- §3.3, extended play (Gitea#11)
case 'extensionVoted': case 'extensionVoted':
return e.agree return e.agree
? { tone: 'plain', text: `Player ${e.player} would play one more Day` } ? { tone: 'plain', text: 'Would play one more Day' }
: { tone: 'plain', text: `Player ${e.player} called time — the game ends here` }; : { tone: 'plain', text: 'Called time — the game ends here' };
case 'dayExtended': case 'dayExtended':
return { tone: 'clock', text: `── The table plays on: Day ${e.day} is added to the timetable ──` }; return { tone: 'clock', text: `── The table plays on: Day ${e.day} is added to the timetable ──` };
case 'playConcluded': case 'playConcluded':
@@ -511,8 +517,8 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
// -- §11, the Yard Office (Gitea#5) // -- §11, the Yard Office (Gitea#5)
case 'yardOfficeRuled': case 'yardOfficeRuled':
return e.take return e.take
? { tone: 'plain', text: `Player ${e.player} sent ${train(e.trainId)} into the Yard Office` } ? { tone: 'plain', text: `Sent ${train(e.trainId)} into the Yard Office` }
: { tone: 'plain', text: `Player ${e.player} kept ${train(e.trainId)} at the Train Order Office` }; : { tone: 'plain', text: `Kept ${train(e.trainId)} at the Train Order Office` };
} }
} }
+62 -9
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 * 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**. * 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 * 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. * 1s and tune down", and that was applied only to switching while this number was invented.
*/ */
@@ -143,10 +143,27 @@ export function kindOf(cause: StepCause): StepKind {
* *
* `pace` has no lower surprise — 0 means off — but an unbounded upper one does: `?pace=300` from * `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 * 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 * dwell and look exactly like a frozen board. Twenty is far past any speed anyone would choose and
* 3 are the ones actually asked for) and well short of unusable. * well short of unusable.
*
* RAISED FROM TEN 2026-09-10, because the ceiling turned out not to be theoretical: Jesse played at
* 10× — the top of the ladder — and reported it *"still a bit fast, but followable"*. A control whose
* slowest setting is not slow enough for the person using it has the wrong ceiling, not the right one
* held firmly.
*/ */
export const MAX_PACE = 10; export const MAX_PACE = 20;
/**
* The speeds the on-screen control offers, slowest last.
*
* `0` is off: every move is drawn at once, as it was before v0.8.0 — TODO #18's "a player who has
* seen it a hundred times will want it off". The ladder runs well past 1 because that is what the
* first real play asked for: Jesse reached for 7×, and although the `?pace=` he used never took
* effect (the splash replaces the query string, so the play page only ever saw `?lobby`), the wish
* was real. Watching a bot shunt cars is the point of this feature, and it is worth as long as it
* takes.
*/
export const PACE_LEVELS = [0, 0.5, 1, 2, 3, 5, 7, 10, 15, 20] as const;
/** /**
* How long to show one step, in ms, at a given speed. * How long to show one step, in ms, at a given speed.
@@ -160,6 +177,28 @@ export function dwellFor(cause: StepCause, pace = 1): number {
return Math.round(DWELL[kindOf(cause)] * Math.min(MAX_PACE, Math.max(0, pace))); 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. * How long to show one STEP — the form the queue actually uses.
* *
@@ -170,10 +209,25 @@ export function dwellFor(cause: StepCause, pace = 1): number {
* have to import `DisplayStep` back from the module that imports `StepCause` from it. * have to import `DisplayStep` back from the module that imports `StepCause` from it.
*/ */
export function dwellForStep( export function dwellForStep(
step: { cause: StepCause; lines: readonly unknown[]; frame: { table: object } }, step: { cause: StepCause; player: number | null; lines: readonly unknown[]; frame: { table: object } },
pace = 1, pace = 1,
): number { ): number {
if (step.lines.length > 0) return dwellFor(step.cause, pace); // Off means off, for the clock as much as for anybody's move.
if (pace <= 0) return 0;
/**
* THE SPEED CONTROL IS ABOUT OTHER PEOPLE, NOT ABOUT THE CLOCK.
*
* A phase keeps its tabled beat at every speed. Measured over 40 turns of a real 3-seat game, the
* waiting split almost evenly — 21.0s of other players against 21.0s of phases turning over — so
* scaling both put 105 seconds of clock-ticking into a 5× game, all of it after the player's own
* move and none of it anything to watch. Jesse, from that game: *"after my turn, when I actually
* execute my turn, I'm still subject to that same delay before it moves on. That makes no sense."*
*
* The phase still gets its beat (TODO #18) — it just does not get longer because somebody wanted
* to watch a bot shunt cars.
*/
const speed = step.player === null ? phaseSpeed(pace) : pace;
if (step.lines.length > 0) return dwellFor(step.cause, speed);
/** /**
* A SILENT STEP EARNS A BEAT ONLY WHEN THE CLOCK TURNED OVER — which is TODO #18 exactly: "give * A SILENT STEP EARNS A BEAT ONLY WHEN THE CLOCK TURNED OVER — which is TODO #18 exactly: "give
* every phase a visible beat", for New Train, the Mainline and the shift change. * every phase a visible beat", for New Train, the Mainline and the shift change.
@@ -182,12 +236,11 @@ export function dwellForStep(
* silently killed #18: a phase can move trains without saying anything, and those steps were being * silently killed #18: a phase can move trains without saying anything, and those steps were being
* flashed past. "Anything that changed the board" is wrong the other way: `submit()` steps * flashed past. "Anything that changed the board" is wrong the other way: `submit()` steps
* `advance()` about 4.6 times per intent and most of those merely hand the turn on, so beating on * `advance()` about 4.6 times per intent and most of those merely hand the turn on, so beating on
* all of them would cost a quarter of an hour a game. The phase turning over is the thing a player * all of them would cost a quarter of an hour a game.
* is being shown, and there are about 180 of those in a full game.
*/ */
const table = step.frame.table as Record<string, unknown>; const table = step.frame.table as Record<string, unknown>;
const turned = 'phase' in table || 'phaseKey' in table || 'stage' in table || 'day' in table; const turned = 'phase' in table || 'phaseKey' in table || 'stage' in table || 'day' in table;
return turned ? dwellFor(step.cause, pace) : 0; return turned ? dwellFor(step.cause, speed) : 0;
} }
/** /**
+62
View File
@@ -130,3 +130,65 @@ function need<T>(value: T | undefined, what: string): T {
} }
return value; return value;
} }
/** A face-up or face-down pile a card can move to or from, as the display addresses it. */
export type PileKey = 'home' | 'salvage' | `dept${number}`;
/**
* WHICH PILES A STEP MOVED — derived, never sent.
*
* The receiver already holds the frame before a step and the frame after it, so which pile changed
* is a diff rather than something the wire has to carry. That matters twice over: nothing is added
* to the protocol, and it cannot drift out of step with the projection the way a hand-maintained
* hint would.
*
* WHY IT IS NEEDED AT ALL. A player watching somebody else draw a card sees seven seconds of an
* unchanged board — the step holds the screen, and the only thing that moved is a number in a panel
* they were not looking at. Jesse, playing v0.8.0.4 at 10×: *"many operations still occurred too fast
* for me to see"*, which was never about duration. Lighting the pile is what tells the eye where.
*
* WHAT EACH ACTION MOVES, measured across four seeds rather than reasoned about:
*
* | intent | piles |
* | ----------------------- | -------------------------------------------------------- |
* | `draw.fromHomeOffice` | `home` — the COUNT only; the card itself stays private |
* | `draw.fromDepartment` | that `dept`, and `home` too when the pile refills from it |
* | `card.discard` | that `dept` |
* | `card.play` | `salvage`, or nothing here when it lands on the board |
* | switching, new trains | nothing here — those show on the board itself |
*/
/**
* 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;
}
+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 };
}
+3
View File
@@ -437,6 +437,8 @@ export type Frame = {
maxCollisionsPerDay: number; maxCollisionsPerDay: number;
maxCollisionsTotal: number; maxCollisionsTotal: number;
collisionsToday: number; collisionsToday: number;
/** What the Day that just ended finished on — see `collisionsPrevDay` in `engine/state.ts`. */
collisionsPrevDay: number;
collisionsTotal: number; collisionsTotal: number;
status: GameState['status']; status: GameState['status'];
outcome: GameState['outcome']; outcome: GameState['outcome'];
@@ -1594,6 +1596,7 @@ export function projectSharedTable(s: GameState) {
maxCollisionsPerDay: s.config.maxCollisionsPerDay, maxCollisionsPerDay: s.config.maxCollisionsPerDay,
maxCollisionsTotal: s.config.maxCollisionsTotal, maxCollisionsTotal: s.config.maxCollisionsTotal,
collisionsToday: s.collisionsToday, collisionsToday: s.collisionsToday,
collisionsPrevDay: s.collisionsPrevDay,
collisionsTotal: s.collisionsTotal, collisionsTotal: s.collisionsTotal,
status: s.status, status: s.status,
outcome: s.outcome, outcome: s.outcome,
+15 -2
View File
@@ -1255,9 +1255,22 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
const said = blindDraw ? 'Drew a card from the Home Office deck' : n.text; const said = blindDraw ? 'Drew a card from the Home Office deck' : n.text;
// "Chose to DRAW a card" does not say WHO, which is unreadable the moment there is more than // "Chose to DRAW a card" does not say WHO, which is unreadable the moment there is more than
// one seat. Only events the player caused are attributed; the Division running itself is not. // one seat. Only events the player caused are attributed; the Division running itself is not.
/**
* A RULING IS MADE AS SUPERINTENDENT, NOT AS YOURSELF (playtest, 2026-09-15: "maybe it could say
* 'Superintendent Player Tom', so it's clear they got the move because they're Superintendent").
* These three are the only moves a player makes out of turn, by holding the office: §8.1's
* clearance, §11's Yard Office offer and §Q's Red Flag prompt. `clearanceGiven` carries no
* player at all — the office made it, whoever holds it — so the actor is what names it.
*/
const RULINGS = ['clearanceGiven', 'yardOfficeRuled', 'redFlagRuled'];
const ruling = RULINGS.includes(e.type) && who !== null;
const mine = who !== null && 'player' in e; const mine = who !== null && 'player' in e;
const text = mine ? `Player ${who} ${uncapitalise(said)}` : said; const text = ruling
game.log.push({ text, tone: mine ? 'act' : n.tone }); ? `Superintendent Player ${who} ${uncapitalise(said)}`
: mine
? `Player ${who} ${uncapitalise(said)}`
: said;
game.log.push({ text, tone: mine || ruling ? 'act' : n.tone });
} }
game.cues.push(...cuesFor(events)); game.cues.push(...cuesFor(events));
+192 -24
View File
@@ -25,7 +25,8 @@ import type { LocalSession, Session } from './session.ts';
import { createLocalSession, createRemoteSession } from './session.ts'; import { createLocalSession, createRemoteSession } from './session.ts';
import type { PlayerIndex } from '../engine/state.ts'; import type { PlayerIndex } from '../engine/state.ts';
import type { PublicDistrict } from '../sim/view.ts'; import type { PublicDistrict } from '../sim/view.ts';
import { createStepQueue } from './step-queue.ts'; import { actorOnScreen, createStepQueue } from './step-queue.ts';
import { PACE_LEVELS } from '../sim/pacing.ts';
import { notice, prefillCode, runLobby } from './lobby.ts'; import { notice, prefillCode, runLobby } from './lobby.ts';
import type { LobbyReady } from './lobby.ts'; import type { LobbyReady } from './lobby.ts';
import { import {
@@ -53,6 +54,7 @@ const REMOTE_KEY = 'station-master.remote.v1';
/** Preset board zoom levels — a fraction applied to the rendered SVG's own pixel dimensions. */ /** Preset board zoom levels — a fraction applied to the rendered SVG's own pixel dimensions. */
const ZOOM_LEVELS = [0.75, 1, 1.25, 1.5] as const; const ZOOM_LEVELS = [0.75, 1, 1.25, 1.5] as const;
/** /**
* Small persisted preferences, kept in a `localStorage` key of their own — separate from * Small persisted preferences, kept in a `localStorage` key of their own — separate from
* `SAVE_KEY`. A save is the seed plus the intents and has to stay portable; none of this belongs * `SAVE_KEY`. A save is the seed plus the intents and has to stay portable; none of this belongs
@@ -182,6 +184,20 @@ function drainIntoQueue(): void {
const reset = session.takeDisplayReset(); const reset = session.takeDisplayReset();
if (reset) stepQueue.reset(reset); if (reset) stepQueue.reset(reset);
stepQueue.push(session.takeDisplaySteps()); stepQueue.push(session.takeDisplaySteps());
/**
* NOWHERE TO ANIMATE MEANS DO NOT QUEUE AT ALL.
*
* Without `requestAnimationFrame` nothing ever advances the queue, so `busy()` would stay true for
* good — and since "Your Move" is now put away while the board is catching up, that would hide a
* player's own actions permanently, leaving Skip as the only way to play the game. Drawing
* everything at once is exactly what `pace = 0` does deliberately, so that is the honest fallback
* rather than a broken page. Caught by `test/web.test.ts`, whose DOM stub has no `rAF` — the same
* stub that has been proving this page still starts since long before any of this existed.
*/
if (typeof requestAnimationFrame !== 'function') {
stepQueue.skip();
return;
}
if (stepQueue.busy()) startAnimationLoop(); if (stepQueue.busy()) startAnimationLoop();
} }
@@ -199,17 +215,23 @@ function drainIntoQueue(): void {
* drawn — from `PublicDistrict`, which `officeSvg` can render as-is because it takes board data and * drawn — from `PublicDistrict`, which `officeSvg` can render as-is because it takes board data and
* has never needed a private viewer. * has never needed a private viewer.
*/ */
function renderWatching(): void { function renderWatching(f?: Frame): void {
const behind = stepQueue.behind(); const behind = stepQueue.behind();
const row = $('watching'); const row = $('watching');
// Collapsed whenever the board is level with the game — which in solitaire is nearly always, and /**
// between turns in multiplayer too. A row that is always there would be a row nobody reads. * VISIBLE WHILE THE BOARD IS BEHIND **OR** STILL SHOWING SOMETHING.
if (behind === 0) { *
* It used to hide the moment `behind` hit zero — which is the moment the LAST step of a burst goes
* up, so the one step a player was most likely to be reading about lost its caption. Collapsed
* otherwise: in solitaire that is nearly always, and between turns in multiplayer too, and a row
* that is always there is a row nobody reads.
*/
if (behind === 0 && !stepQueue.busy()) {
row.hidden = true; row.hidden = true;
return; return;
} }
row.hidden = false; row.hidden = false;
$('watching-behind').textContent = `${behind} behind`; $('watching-behind').textContent = behind === 0 ? 'catching up' : `${behind} behind`;
/** /**
* THE CAPTION IS #15, and this is where that item lands rather than as a line of its own. * THE CAPTION IS #15, and this is where that item lands rather than as a line of its own.
* *
@@ -218,8 +240,29 @@ function renderWatching(): void {
* The queue IS that, so the caption simply names the step being shown, and the counter beside it * The queue IS that, so the caption simply names the step being shown, and the counter beside it
* says how much of the wait is left. * says how much of the wait is left.
*/ */
/**
* WHO, THEN WHAT — Jesse, 2026-09-09: *"it didn't tell me what the actual action was, like who I
* was waiting on or what they were doing. I knew I was behind, but I wasn't sure what I was
* supposed to be looking for."*
*
* The caption was there; it was the wrong half of the sentence. Half the waiting is automatic
* phases, whose narration reads "Mainline" — accurate, and no answer at all to "who am I waiting
* on". So the name goes first, and a phase says so in as many words rather than leaving the reader
* to infer that nobody is acting.
*
* The narrated line is used as it stands otherwise, because `record()` already prefixes it with the
* player — "Player Bot 1 moved Train 3 (−1,−2) → (−1,1)" — so a second name would stutter.
*/
const showing = stepQueue.showing(); const showing = stepQueue.showing();
$('watching-what').textContent = showing?.lines[0]?.text ?? ''; const said = showing?.lines[0]?.text ?? '';
const who =
showing === null || showing === undefined
? ''
: showing.player === null
? 'The Division'
: (f?.players[showing.player]?.name ?? `Seat ${seatLabel(showing.player)}`);
// A player action already names its actor; a phase does not, so it is introduced.
$('watching-what').textContent = showing?.player === null && said !== '' ? `${who}: ${said}` : said;
/** /**
* SKIP COSTS THE ANIMATION AND NEVER THE INFORMATION. * SKIP COSTS THE ANIMATION AND NEVER THE INFORMATION.
* *
@@ -287,12 +330,25 @@ function startAnimationLoop(): void {
*/ */
console.error('display queue stopped:', err); console.error('display queue stopped:', err);
animating = false; animating = false;
// Do not strand the player behind a queue that can no longer advance: jump the board to the
// live position, which brings "Your Move" back with it.
try {
stepQueue.skip();
} catch {
/* nothing further to try — the authoritative Frame is still what the rest of the page draws */
}
render();
return; return;
} }
if (!stepQueue.busy()) { if (!stepQueue.busy()) {
animating = false; animating = false;
// One last render so the "N behind" row collapses the moment the board is level. /**
renderWatching(); * A FULL RENDER, not just the row. The board being level again is what brings "Your Move"
* back and clears the last lit pile, so redrawing only the catching-up row would leave the
* action list hidden until something else happened to trigger a render — which, when the game
* is waiting on this player, is nothing at all.
*/
render();
return; return;
} }
requestAnimationFrame(tick); requestAnimationFrame(tick);
@@ -437,12 +493,14 @@ function piecePreview(links: string[], label: string): string {
* are in the Day the same way and with the same violet highlight. It used to live here alone. * are in the Day the same way and with the same violet highlight. It used to live here alone.
*/ */
function renderTurnChart(f: Frame): void { function renderTurnChart(f: Frame): void {
const actorName = f.actor === null ? null : (f.players[f.actor]?.name ?? null); // The move on screen, not the live one, while the board is still catching up (Gitea#25).
const { actor, replaying } = actorOnScreen(stepQueue, f.actor);
const actorName = actor === null ? null : (f.players[actor]?.name ?? null);
// Named only at a table with more than one seat: in solitaire the Fedora is always yours, and a // Named only at a table with more than one seat: in solitaire the Fedora is always yours, and a
// chip that can never change is a chip to read past. // chip that can never change is a chip to read past.
const superName = const superName =
f.players.length > 1 ? (f.players.find((p) => p.index === f.superintendent)?.name ?? null) : null; f.players.length > 1 ? (f.players.find((p) => p.index === f.superintendent)?.name ?? null) : null;
$('turnchart').innerHTML = turnChartHtml(f, actorName, superName); $('turnchart').innerHTML = turnChartHtml(replaying ? { ...f, awaiting: null } : f, actorName, superName);
} }
/** /**
@@ -944,6 +1002,7 @@ function beginRemote(ready: LobbyReady, rejoining = false): void {
// banner (`#presence`), and it holds a beat so the game visibly begins. // banner (`#presence`), and it holds a beat so the game visibly begins.
openHandoff(); openHandoff();
session = createRemoteSession(ready.token, ready.seat, abandonRemote); session = createRemoteSession(ready.token, ready.seat, abandonRemote);
remoteToken = ready.token;
rejoiningRemote = rejoining; rejoiningRemote = rejoining;
applyCapabilities(); applyCapabilities();
// A LocalSession has data the instant it is constructed; a RemoteSession does not — its first // A LocalSession has data the instant it is constructed; a RemoteSession does not — its first
@@ -1111,6 +1170,14 @@ function start(): void {
* the lobby's job. Hidden rather than disabled: a greyed-out Undo in a multiplayer game invites the * the lobby's job. Hidden rather than disabled: a greyed-out Undo in a multiplayer game invites the
* question "why not?" every turn, and the honest answer is that the control does not belong there. * question "why not?" every turn, and the honest answer is that the control does not belong there.
*/ */
/**
* The session token of a server-backed game, or null in solitaire (playtest, 2026-09-15: "most of the
* time, I want to go ahead and just save it as a JSON file"). It is the seat's proof of identity to
* `/api/save`, exactly as it is to `/api/stream` — a save is the seed and the moves, every one of which
* is already on this player's screen.
*/
let remoteToken: string | null = null;
function applyCapabilities(): void { function applyCapabilities(): void {
const c = session.capabilities; const c = session.capabilities;
const hide = (id: string, on: boolean): void => { const hide = (id: string, on: boolean): void => {
@@ -1118,7 +1185,7 @@ function applyCapabilities(): void {
if (el) el.hidden = !on; if (el) el.hidden = !on;
}; };
hide('undo', c.undo); hide('undo', c.undo);
hide('savefile', c.saveLocal); hide('savefile', c.saveLocal || remoteToken !== null);
hide('newgame', c.newGame); hide('newgame', c.newGame);
// Creating or joining ANOTHER multiplayer game from inside a running one is not a thing this // Creating or joining ANOTHER multiplayer game from inside a running one is not a thing this
// page offers — same reasoning as `newgame`, and the same capability answers both. // page offers — same reasoning as `newgame`, and the same capability answers both.
@@ -1262,7 +1329,7 @@ function render(): void {
renderTurnChart(f); renderTurnChart(f);
renderPresence(f); renderPresence(f);
renderWatching(); renderWatching(f);
$('revenue').textContent = String(f.revenue); $('revenue').textContent = String(f.revenue);
/** /**
* THE OBJECTIVE, WITHOUT THE COMMENTARY. * THE OBJECTIVE, WITHOUT THE COMMENTARY.
@@ -1291,8 +1358,10 @@ function render(): void {
// -- division // -- division
$('division').innerHTML = divisionSvg(f.division, { $('division').innerHTML = divisionSvg(f.division, {
players: f.players, players: f.players,
actor: f.actor, actor: actorOnScreen(stepQueue, f.actor).actor,
viewer: f.viewer, viewer: f.viewer,
// A Realignment changes the Division under everyone; flashed only while the step that did it is up.
flash: stepQueue.busy() ? stepQueue.flashing() : [],
}); });
renderSeatingChain(f); renderSeatingChain(f);
applyZoom($('division')); applyZoom($('division'));
@@ -1482,7 +1551,9 @@ function render(): void {
* reach. Drawn like the hand so they read as cards, dashed and unlit because taking one is a draw * reach. Drawn like the hand so they read as cards, dashed and unlit because taking one is a draw
* action rather than a click on the card itself. * action rather than a click on the card itself.
*/ */
$('depts').innerHTML = pilesHtml(f); // The pile the move being WATCHED just touched, lit for as long as that step is on screen. Empty
// whenever the board is level with the game, or when the move was this player's own.
$('depts').innerHTML = pilesHtml(f, stepQueue.busy() ? stepQueue.lit() : []);
renderYards(f); renderYards(f);
@@ -1575,7 +1646,16 @@ function render(): void {
// -- log // -- log
const log = $('log'); const log = $('log');
const allLines = session.lines(); /**
* THE LOG IS HELD BACK WITH THE BOARD (playtest, 2026-09-15).
*
* A push carries its narration and its display steps together, so every line of a bot's turn was in
* this panel before the board had drawn a single move of it — the history ran ahead of the "N behind"
* counter it is meant to match. Those lines are the TAIL of the log, so exactly the ones belonging to
* steps still queued are withheld, and each appears as its step goes up.
*/
const heldBack = stepQueue.pendingLines();
const allLines = heldBack > 0 ? session.lines().slice(0, -heldBack) : session.lines();
const shownLines = allLines.slice(-60); const shownLines = allLines.slice(-60);
/** /**
* WHERE THE GAME BEGAN. In a multiplayer game the bots move the instant the host presses Start, so * WHERE THE GAME BEGAN. In a multiplayer game the bots move the instant the host presses Start, so
@@ -1956,6 +2036,29 @@ function renderActions(
renderEnding(el, f); renderEnding(el, f);
return; return;
} }
/**
* YOUR MOVE IS PUT AWAY WHILE THE BOARD IS CATCHING UP — Jesse, 2026-09-10: *"your actions should
* be hidden while catching up."*
*
* Two reasons, and the second is the one that changed my mind about it. The board on screen is
* behind the game, so a move offered here is a move against a position that has already moved on —
* the menu is computed from the CURRENT state and would be acted on while looking at an older one.
* And the display had grown to four things demanding attention at once — the district, the history,
* the catching-up row and now a lit pile — which is what made the pile highlight so easy to miss.
* Taking the action list out of that competition while there is nothing to decide anyway is the
* cheapest way to quieten it.
*
* NOT A BLOCK. Skip is one click away and sits at the left of the row, so the wait is always
* voluntary; this replaces the buttons with the reason they are gone, rather than leaving a live
* menu over a stale board.
*/
if (stepQueue.busy()) {
el.innerHTML =
'<div class="dim">Catching up on what everyone else did — your move is here when the board is ' +
'level with the game. <b>Skip</b> jumps straight to it.</div>';
return;
}
// The game is running, so the next ending — an extended Day's, or a fresh game's — is entitled to // The game is running, so the next ending — an extended Day's, or a fresh game's — is entitled to
// put its results up unasked again (Gitea#11). // put its results up unasked again (Gitea#11).
resultsShown = false; resultsShown = false;
@@ -2236,21 +2339,40 @@ function renderActions(
* hundred bytes, so a finished game can be emailed or dropped on the site's replay directory — * hundred bytes, so a finished game can be emailed or dropped on the site's replay directory —
* where a rendered page would have been megabytes. * where a rendered page would have been megabytes.
*/ */
function downloadSave(): void { function writeFile(name: string, data: string): void {
// The button this fires from is hidden by `applyCapabilities()` for any session that cannot save
// (`#savefile`), but nothing stops this function being called directly, so the guard is repeated
// here rather than only trusted to the DOM.
if (!isLocal(session)) return;
const data = JSON.stringify(session.save(), null, 1);
const blob = new Blob([data], { type: 'application/json' }); const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = `station-master-seed${session.seed()}-day${session.view().day}.json`; a.download = name;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
async function downloadSave(): Promise<void> {
const f = session.view();
const stamp = `day${f.day}-stage${f.stage}`;
/**
* A SERVER-BACKED GAME HAS NO LOCAL SAVE TO HAND OVER, so it asks the server for its own — the seat's
* token is the gate (`/api/save`), the same one the stream and every intent already use. The StartOS
* 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 };
writeFile(`station-master-${stamp}.json`, JSON.stringify(body.save, null, 1));
} catch {
// Offline, or the game has been ended under us: the button simply does nothing, which is the
// same thing every other server call on this page does when the server is not there.
}
return;
}
writeFile(`station-master-seed${session.seed()}-${stamp}.json`, JSON.stringify(session.save(), null, 1));
}
function save(): void { function save(): void {
if (!isLocal(session)) return; if (!isLocal(session)) return;
try { try {
@@ -2286,7 +2408,7 @@ document.head.appendChild(pageStyle);
installTooltips(); installTooltips();
const saveBtn = document.getElementById('savefile'); const saveBtn = document.getElementById('savefile');
if (saveBtn) saveBtn.onclick = downloadSave; if (saveBtn) saveBtn.onclick = () => void downloadSave();
/** /**
* Forget the saved game and deal a fresh one. * Forget the saved game and deal a fresh one.
@@ -2603,6 +2725,52 @@ function runSolitaireSetup(params: URLSearchParams, hasSave = false, live: Frame
dealBtn.onclick = () => commitNewGame(ss, seedField?.value ?? ''); dealBtn.onclick = () => commitNewGame(ss, seedField?.value ?? '');
} }
/**
* PLAYBACK SPEED — v0.8.0.3, TODO #13.
*
* Persisted per viewer in `Settings`, so it survives the navigation that was eating `?pace=`. The
* queue reads `settings.pace` through a closure on every step, so a change here takes effect on the
* very next move rather than the next game.
*/
const paceSlowerBtn = document.getElementById('paceslower') as HTMLButtonElement | null;
const paceFasterBtn = document.getElementById('pacefaster') as HTMLButtonElement | null;
const paceLabel = document.getElementById('pacelabel');
if (paceSlowerBtn && paceFasterBtn && paceLabel) {
const nearestPace = (): number => {
// A saved or URL value need not be on the ladder — `?pace=7` and a hand-edited setting are both
// legitimate — so the buttons step from whichever preset is closest rather than refusing to move.
const want = PACE_OVERRIDE ?? settings.pace;
return PACE_LEVELS.reduce((best, p) => (Math.abs(p - want) < Math.abs(best - want) ? p : best), PACE_LEVELS[0]);
};
const paintPace = (): void => {
const p = PACE_OVERRIDE ?? settings.pace;
paceLabel.textContent = p === 0 ? 'off' : `${p}×`;
const i = PACE_LEVELS.indexOf(nearestPace() as (typeof PACE_LEVELS)[number]);
paceSlowerBtn.disabled = i >= PACE_LEVELS.length - 1;
paceFasterBtn.disabled = i <= 0;
// A `?pace=` in the URL wins over the setting, so say so rather than showing dead buttons.
if (PACE_OVERRIDE !== null) {
paceSlowerBtn.disabled = true;
paceFasterBtn.disabled = true;
paceLabel.textContent = `${PACE_OVERRIDE}× (URL)`;
}
};
const stepPace = (by: number): void => {
const i = PACE_LEVELS.indexOf(nearestPace() as (typeof PACE_LEVELS)[number]);
const next = PACE_LEVELS[Math.min(PACE_LEVELS.length - 1, Math.max(0, i + by))];
if (next === undefined) return;
saveSettings({ pace: next });
paintPace();
// The row's countdown is measured in steps that will dwell, so a change to 0 empties it at once.
renderWatching();
};
// Slower is a BIGGER multiplier, so "−" walks up the ladder. Labelled by what it does to the game,
// not to the number: a player pressing "slower" wants to watch for longer.
paceSlowerBtn.onclick = () => stepPace(1);
paceFasterBtn.onclick = () => stepPace(-1);
paintPace();
}
const zoomOutBtn = document.getElementById('zoomout') as HTMLButtonElement | null; const zoomOutBtn = document.getElementById('zoomout') as HTMLButtonElement | null;
const zoomInBtn = document.getElementById('zoomin') as HTMLButtonElement | null; const zoomInBtn = document.getElementById('zoomin') as HTMLButtonElement | null;
const zoomLabel = document.getElementById('zoomlabel'); const zoomLabel = document.getElementById('zoomlabel');
+84 -8
View File
@@ -52,25 +52,61 @@ export function handHtml(f: Frame, canPlay: (boolean | null)[] = []): string {
* Only the top card may ever be drawn, so the depth is a count and not a hint: everything below it * Only the top card may ever be drawn, so the depth is a count and not a hint: everything below it
* is out of reach, and choosing where to discard is choosing what to put there. * is out of reach, and choosing where to discard is choosing what to put there.
*/ */
export function pilesHtml(f: Frame): string { export function pilesHtml(f: Frame, lit: readonly string[] = []): string {
const pile = (label: string, top: string, depth: number, why: string, extra = '', slot = -1): string => { const pile = (
key: string,
label: string,
top: string,
depth: number,
why: string,
extra = '',
slot = -1,
faceDown = false,
): string => {
const tip = [why, extra].filter(Boolean).join(' · '); const tip = [why, extra].filter(Boolean).join(' · ');
// A Department is a DROP TARGET for a discard. The attribute is always emitted; only the play // A Department is a DROP TARGET for a discard. The attribute is always emitted; only the play
// page binds a click to it, and only while a card is waiting to be discarded — so the replay // page binds a click to it, and only while a card is waiting to be discarded — so the replay
// viewer draws exactly the same markup and nothing there is clickable. // viewer draws exactly the same markup and nothing there is clickable.
const target = slot >= 0 ? ` data-dept="${slot}"` : ''; const target = slot >= 0 ? ` data-dept="${slot}"` : '';
// `lit` marks the pile the move being watched just touched — see `changedPiles`.
const cls = `handcard${faceDown ? ' facedown' : ''}${lit.includes(key) ? ' pilelit' : ''}`;
return ( return (
`<div class="handcard"${target}${tip ? ` data-tip="${esc(tip)}"` : ''} tabindex="0">` + `<div class="${cls}"${target}${tip ? ` data-tip="${esc(tip)}"` : ''} tabindex="0">` +
`<div class="pilehd"><span>${esc(label)}</span><span class="depth">${depth}</span></div>` + `<div class="pilehd"><span>${esc(label)}</span><span class="depth">${depth}</span></div>` +
`<b>${esc(top)}</b></div>` `<b>${esc(top)}</b></div>`
); );
}; };
return ( return (
/**
* THE HOME OFFICE DECK, which the screen had never drawn.
*
* `f.deck` has carried the face-down count since the Frame existed and nothing read it — the
* exact shape of display gap `test/display-gaps.test.ts` was written to sweep for, surviving in
* the panel that draws every OTHER pile. Asked for by Jesse 2026-09-10 for a second reason: a
* player drawing from it is the commonest move nobody can see, so it needs somewhere to flash.
*
* FIRST, because that is the order a card travels: out of here, into a hand, then onto a
* Department or the Salvage Yard. Face down, so the card slot says so rather than naming a card
* — the whole point of this pile is that nobody knows what is on top.
*/
pile(
'home',
'Home Office',
'face down',
f.deck,
'The draw deck. Face down — nobody sees what is on top, and a card drawn from here is private ' +
'to whoever drew it. When it runs out, the Salvage Yard and the Departments are swept back ' +
'into it.',
'',
-1,
true,
) +
f.departments f.departments
.map((d, i) => { .map((d, i) => {
const depth = f.departmentDepth[i] ?? 0; const depth = f.departmentDepth[i] ?? 0;
const under = depth - 1; const under = depth - 1;
return pile( return pile(
`dept${i}`,
`Dept ${i + 1}`, `Dept ${i + 1}`,
d, d,
depth, depth,
@@ -81,6 +117,7 @@ export function pilesHtml(f: Frame): string {
}) })
.join('') + .join('') +
pile( pile(
'salvage',
'Salvage', 'Salvage',
f.salvage.top, f.salvage.top,
f.salvage.depth, f.salvage.depth,
@@ -180,7 +217,7 @@ export function dayEndHtml(f: Frame): string {
ahead + ahead +
standingsHtml(f) + standingsHtml(f) +
targetHtml(f) + targetHtml(f) +
collisionsHtml(f) collisionsHtml(f, ended)
); );
} }
@@ -238,13 +275,28 @@ function targetHtml(f: Frame): string {
* its config and enforces neither, so reporting a collision budget there would put a rule on * its config and enforces neither, so reporting a collision budget there would put a rule on
* screen that this game does not have. * screen that this game does not have.
*/ */
function collisionsHtml(f: Frame): string { function collisionsHtml(f: Frame, endedDay?: number): string {
const scoredOnCollisions = const scoredOnCollisions =
(f.mode === 'competitive' || f.mode === 'coop') && (f.mode === 'competitive' || f.mode === 'coop') &&
(f.maxCollisionsTotal > 0 || f.maxCollisionsPerDay > 0); (f.maxCollisionsTotal > 0 || f.maxCollisionsPerDay > 0);
return scoredOnCollisions if (!scoredOnCollisions) return '';
? `<p>Collisions: <b>${f.collisionsToday}</b> today, <b>${f.collisionsTotal}</b> in all.</p>` /**
: ''; * "TODAY" IS THE WRONG WORD IN A DAY-END DIALOG, and it read as a contradiction.
*
* That dialog is drawn from the frame whose `day` went UP — which is the same frame in which
* `collisionsToday` was reset — so it reported 0 however many there had been. Jesse, 2026-09-09,
* at the end of a Day 1 with two collisions in it: "it shows a total of two collisions, but zero
* today ... that does seem to be a contradiction."
*
* So when the caller knows which Day just ended it says so by name, and reads the count captured at
* the rollover. The end-of-game results screen passes nothing and keeps "today", where the Day has
* not turned over and the word is accurate.
*/
const [count, when] =
endedDay === undefined
? [f.collisionsToday, 'today']
: [f.collisionsPrevDay, `on Day ${endedDay}`];
return `<p>Collisions: <b>${count}</b> ${when}, <b>${f.collisionsTotal}</b> in all.</p>`;
} }
/** /**
@@ -661,6 +713,30 @@ h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:#8b94a3;ma
.handcard:focus{outline:2px solid #4d6fa8;outline-offset:1px} .handcard:focus{outline:2px solid #4d6fa8;outline-offset:1px}
.cardrow.ref .handcard{background:#1c2129;border-style:dashed;border-color:#39424e;color:#b6bec9} .cardrow.ref .handcard{background:#1c2129;border-style:dashed;border-color:#39424e;color:#b6bec9}
.handcard.unplayable{color:#7d8794;border-color:#39424e} .handcard.unplayable{color:#7d8794;border-color:#39424e}
/* THE HOME OFFICE DECK. Face down, so its card slot names no card — it says so instead, in the
dimmed voice the rest of the panel uses for "nothing to read here". */
.handcard.facedown > b{color:#6f7885;font-style:italic;font-weight:400}
/* THE PILE A WATCHED MOVE JUST TOUCHED (v0.8.1).
A STATE, NOT A FLASH, and that is the whole point. The .tt-slot.fresh rule above animates for a fixed
1.5s, which is right for a die roll nobody is waiting on — but a step can hold the screen for
seven seconds at 10x, so a fixed animation would be over long before the pause it belongs to and
the player would be back to staring at an unchanged board. The flash-in marks the moment; the lit
border and background stay for exactly as long as the step is up, because the class is on the
element only while that step is the one being shown. */
.handcard.pilelit{border-color:#8fd6a0;background:#1d3327;box-shadow:0 0 0 2px #2f6b47,0 0 14px rgba(143,214,160,.55);
animation:pilepulse 1.15s ease-in-out infinite}
/* A PULSE FOR THE WHOLE DWELL, not one flash at the start. Measured: at 10x a pile stays lit for
just under seven seconds, so the highlight was never brief — but a single 0.45s flash-in and a
dark green fill were easy to miss entirely while watching the district. Jesse: "caught one flash
deck light up for just a very brief moment, but couldn't see that with what bot was doing in
office area and history and catch up area all at same time." Something still moving keeps drawing
the eye for as long as the move is up; a state that settles stops asking to be looked at. */
@keyframes pilepulse{0%,100%{background:#1d3327;box-shadow:0 0 0 2px #2f6b47,0 0 14px rgba(143,214,160,.45)}
50%{background:#2f6b47;box-shadow:0 0 0 3px #8fd6a0,0 0 22px rgba(143,214,160,.85)}}
/* Motion is the point here, so the reduced-motion fallback has to be loud in a different way rather
than simply not moving: a solid ring and a brighter fill, held. */
@media(prefers-reduced-motion:reduce){
.handcard.pilelit{animation:none;background:#2f6b47;box-shadow:0 0 0 3px #8fd6a0}}
.handcard.unplayable::after{content:"";position:absolute;inset:0;border-radius:5px;pointer-events:none; .handcard.unplayable::after{content:"";position:absolute;inset:0;border-radius:5px;pointer-events:none;
background:repeating-linear-gradient(45deg,transparent 0 5px,rgba(150,160,175,.20) 5px 6px)} background:repeating-linear-gradient(45deg,transparent 0 5px,rgba(150,160,175,.20) 5px 6px)}
/* THE CARD JUST DRAWN. It sits first in the row, and this says which one that is — three cards that /* THE CARD JUST DRAWN. It sits first in the row, and this says which one that is — three cards that
+13 -1
View File
@@ -125,6 +125,7 @@ section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
.wbehind{font-variant-numeric:tabular-nums;font-weight:700;color:#c9cee0; .wbehind{font-variant-numeric:tabular-nums;font-weight:700;color:#c9cee0;
background:#22263a;border:1px solid #343a52;border-radius:10px;padding:1px 8px;white-space:nowrap} background:#22263a;border:1px solid #343a52;border-radius:10px;padding:1px 8px;white-space:nowrap}
#watching-skip{font-size:11px;padding:2px 10px;border-color:#e0b060;color:#e0b060;flex:none} #watching-skip{font-size:11px;padding:2px 10px;border-color:#e0b060;color:#e0b060;flex:none}
#watching-who{color:#c9cee0;font-weight:700}
#presence:empty{display:none} #presence:empty{display:none}
/* division strip */ /* division strip */
#division{display:flex;gap:7px;overflow-x:auto;padding-bottom:4px} #division{display:flex;gap:7px;overflow-x:auto;padding-bottom:4px}
@@ -872,6 +873,14 @@ ul.blocked li{padding:2px 0}
<span class="zoom" title="Zoom the Division map and your Office Area. Both already scroll — this only changes their size."> <span class="zoom" title="Zoom the Division map and your Office Area. Both already scroll — this only changes their size.">
<button id="zoomout" aria-label="Zoom out">−</button><span id="zoomlabel">100%</span><button id="zoomin" aria-label="Zoom in">+</button> <button id="zoomout" aria-label="Zoom out">−</button><span id="zoomlabel">100%</span><button id="zoomin" aria-label="Zoom in">+</button>
</span> </span>
<!-- HOW FAST OTHER PLAYERS' TURNS PLAY BACK — v0.8.0.3, TODO #13.
A CONTROL RATHER THAN ONLY A URL PARAMETER. `?pace=` shipped first and is unreachable through
the front door: `index.html`'s two doors are `play.html?lobby` and `play.html?solitaire`, so
arriving from the splash REPLACES the query string and any pace with it. Jesse played a whole
game believing he was at 7x when he was at 1x. -->
<span class="zoom" title="How long another player's or a bot's move is held on screen before the next one. Yours are never delayed. Off draws every move at once, as it did before v0.8.0.">
<button id="paceslower" aria-label="Slower playback">−</button><span id="pacelabel">1×</span><button id="pacefaster" aria-label="Faster playback">+</button>
</span>
<button id="undo" title="Take the last action back. The save is the seed plus the moves made, so this replays the game without the last one — as far back as you like.">Undo</button> <button id="undo" title="Take the last action back. The save is the seed plus the moves made, so this replays the game without the last one — as far back as you like.">Undo</button>
<button id="savefile" title="Download this game as a save file you can replay or share">Save replay</button> <button id="savefile" title="Download this game as a save file you can replay or share">Save replay</button>
<button id="newgame" title="Set up a fresh game — the seed, the table, the opening hand and what the three economies pay. Opens the same screen a new solitaire game starts from, with your current rules filled in; your game in progress is kept until you press Deal, and Continue puts it straight back.">New game</button> <button id="newgame" title="Set up a fresh game — the seed, the table, the opening hand and what the three economies pay. Opens the same screen a new solitaire game starts from, with your current rules filled in; your game in progress is kept until you press Deal, and Continue puts it straight back.">New game</button>
@@ -906,9 +915,12 @@ ul.blocked li{padding:2px 0}
and Skip. Empty and collapsed whenever the board is level with the game, which in solitaire is and Skip. Empty and collapsed whenever the board is level with the game, which in solitaire is
almost always. --> almost always. -->
<div id="watching" hidden> <div id="watching" hidden>
<!-- SKIP FIRST, on the left. It sat on the far right and a player's eye is on the countdown, not at
the other end of the row — Jesse, 2026-09-09: "the skip button should be on the far left, in
front of where it says [the count], so it's always close to where people are looking." -->
<button id="watching-skip" class="ghost" type="button" title="Stop animating and jump the board to where the game actually is. Nothing is lost — every line is already in the History panel.">Skip</button>
<span id="watching-behind" class="wbehind"></span> <span id="watching-behind" class="wbehind"></span>
<span id="watching-what"></span> <span id="watching-what"></span>
<button id="watching-skip" class="ghost" type="button" title="Stop animating and jump the board to where the game actually is. Nothing is lost — every line is already in the History panel.">Skip</button>
</div> </div>
<main> <main>
+23
View File
@@ -40,6 +40,29 @@ if (heroImage && lightbox) {
* other way costs a click and a lobby that says it cannot reach a server — which is legible, and * other way costs a click and a lobby that says it cannot reach a server — which is legible, and
* recoverable. So a slow or flaky probe leaves the door alone; only a definite answer closes it. * recoverable. So a slow or flaky probe leaves the door alone; only a definite answer closes it.
*/ */
/**
* CARRY `?pace=` THROUGH THE DOORS — v0.8.0.3.
*
* Both doors are static hrefs that REPLACE the query string (`play.html?lobby`,
* `play.html?solitaire`), so a `pace` typed on this page was silently dropped on the way in: Jesse
* played a whole game believing he was at 7× when the play page had only ever seen `?lobby`. The
* durable answer is the speed control on the play screen, which persists per viewer — this keeps the
* URL lever honest for handing two playtesters different speeds, which is the only thing it was ever
* for.
*/
try {
const pace = new URLSearchParams(location.search).get('pace');
if (pace !== null) {
for (const door of Array.from(document.querySelectorAll('a.door'))) {
const href = door.getAttribute('href');
// Only the doors into the game, and only ones that have not been disabled above.
if (href?.startsWith('./play.html?')) door.setAttribute('href', `${href}&pace=${encodeURIComponent(pace)}`);
}
}
} catch {
// A door that keeps its own href is the status quo, not a broken page.
}
const mpDoor = document.getElementById('door-multiplayer'); const mpDoor = document.getElementById('door-multiplayer');
if (mpDoor) { if (mpDoor) {
const close = (): void => { const close = (): void => {
+61 -1
View File
@@ -19,7 +19,8 @@
import type { PublicFrame } from '../sim/view.ts'; import type { PublicFrame } from '../sim/view.ts';
import type { DisplayStep } from '../sim/display-step.ts'; import type { DisplayStep } from '../sim/display-step.ts';
import { applyPublicDelta } from '../sim/public-delta.ts'; import { applyPublicDelta, changedDivisionCards, changedPiles } from '../sim/public-delta.ts';
import type { PileKey } from '../sim/public-delta.ts';
import { dwellForStep } from '../sim/pacing.ts'; import { dwellForStep } from '../sim/pacing.ts';
export type StepQueue = { export type StepQueue = {
@@ -43,10 +44,52 @@ export type StepQueue = {
behind(): number; behind(): number;
/** The last step actually shown, for the caption line (#15). Null before anything has been shown. */ /** The last step actually shown, for the caption line (#15). Null before anything has been shown. */
showing(): DisplayStep | null; showing(): DisplayStep | null;
/**
* The piles the step now on screen moved, for the display to light.
*
* Here because this is the only place that holds both the frame before a step and the frame after
* it — deriving it anywhere else would mean keeping a second copy of the board in step.
*/
lit(): readonly PileKey[];
/** True while there is anything left to show. */ /** True while there is anything left to show. */
busy(): boolean; busy(): boolean;
/**
* 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[];
}; };
/**
* WHOSE MOVE THE SCREEN IS SHOWING (Gitea#25).
*
* The game and the board on screen are two different moments. The server plays every bot move the
* instant a human's turn ends (`driveBots`), so the LIVE game is nearly always waiting on the human —
* while this queue is still replaying the bots, step by step. The turn chart and the Division map's
* move marker read the live actor, so a table of one person and three bots said "waiting on" that
* person throughout, against a playback row naming the bot actually moving.
*
* While the queue is behind or still showing a step, the answer is that step's player — `null` for an
* automatic phase, which is "the Division is running itself". Otherwise it is the live actor, and
* `replaying` is false so a caller can keep live-only detail, such as a ruling the game is waiting on,
* off a screen that has not caught up with it yet.
*/
export function actorOnScreen(
queue: Pick<StepQueue, 'behind' | 'busy' | 'showing'>,
live: number | null,
): { actor: number | null; replaying: boolean } {
if (queue.behind() === 0 && !queue.busy()) return { actor: live, replaying: false };
const shown = queue.showing();
return shown === null ? { actor: live, replaying: false } : { actor: shown.player, replaying: true };
}
/** /**
* `pace` is read on every step rather than captured, so changing the setting takes effect at once. * `pace` is read on every step rather than captured, so changing the setting takes effect at once.
* *
@@ -65,6 +108,8 @@ export function createStepQueue(
): StepQueue { ): StepQueue {
let shown: PublicFrame | null = null; let shown: PublicFrame | null = null;
let last: DisplayStep | null = null; let last: DisplayStep | null = null;
let litPiles: readonly PileKey[] = [];
let flashedCards: readonly number[] = [];
let pending: DisplayStep[] = []; let pending: DisplayStep[] = [];
/** When the step now on screen is due to give way. Null when nothing is waiting. */ /** When the step now on screen is due to give way. Null when nothing is waiting. */
let dueAt: number | null = null; let dueAt: number | null = null;
@@ -75,8 +120,17 @@ export function createStepQueue(
/** Applies one step to the displayed board. A step's delta chains off the previous step's frame. */ /** Applies one step to the displayed board. A step's delta chains off the previous step's frame. */
const show = (step: DisplayStep): void => { const show = (step: DisplayStep): void => {
const before = shown;
shown = applyPublicDelta(shown, step.frame); shown = applyPublicDelta(shown, step.frame);
last = step; last = step;
/**
* NOT FOR YOUR OWN MOVES. You drew that card; you do not need the deck flashed at you. Same rule
* that gives your own steps no dwell — the display is for watching everybody else.
*/
litPiles = step.player !== null && step.player === viewer() ? [] : changedPiles(before, shown);
// A Realignment changes the Division under everyone, so it is flashed for the player who did it
// too — unlike a pile, which only tells the drawer what they already know.
flashedCards = changedDivisionCards(before, shown);
}; };
return { return {
@@ -84,6 +138,9 @@ export function createStepQueue(
shown = frame; shown = frame;
pending = []; pending = [];
dueAt = null; dueAt = 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 // `last` deliberately survives: a reconnect should not blank the caption line, and the
// sentence describing the most recent action is still true. // sentence describing the most recent action is still true.
}, },
@@ -136,6 +193,7 @@ export function createStepQueue(
current: () => shown, current: () => shown,
behind: () => pending.filter((s) => dwell(s) > 0).length, behind: () => pending.filter((s) => dwell(s) > 0).length,
showing: () => last, showing: () => last,
lit: () => litPiles,
/** /**
* STILL SHOWING SOMETHING, not just still holding something back. * STILL SHOWING SOMETHING, not just still holding something back.
* *
@@ -147,6 +205,8 @@ export function createStepQueue(
* `dueAt` is non-null exactly while the step on screen has time left, so the two together mean * `dueAt` is non-null exactly while the step on screen has time left, so the two together mean
* "there is more to come, or what is up has not had its moment yet". * "there is more to come, or what is up has not had its moment yet".
*/ */
pendingLines: () => pending.reduce((n, s) => n + s.lines.length, 0),
flashing: () => flashedCards,
busy: () => pending.length > 0 || dueAt !== null, busy: () => pending.length > 0 || dueAt !== null,
}; };
} }
+72 -1
View File
@@ -1141,7 +1141,12 @@ describe('the history says WHY a train moved, and says it truthfully', () => {
// A train ahead of it in the same Subdivision, running the SAME way — §8.1's fourth condition, // A train ahead of it in the same Subdivision, running the SAME way — §8.1's fourth condition,
// which is the Superintendent's call rather than an absolute bar. // which is the Superintendent's call rather than an absolute bar.
const ahead = s.division.nodes.findIndex((n) => n.kind === 'mainline'); //
// AHEAD MEANS EAST OF THE OFFICE for this eastbound train. This used to take the FIRST Mainline card
// in the Division, which is west of the Office — behind the train — and still expected a ruling,
// which is exactly the fault Gitea#26 reported. The card is now one the train would actually follow.
const office = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
const ahead = s.division.nodes.findIndex((n, i) => i > office && n.kind === 'mainline');
const node = s.division.nodes[ahead]; const node = s.division.nodes[ahead];
assert.equal(node?.kind, 'mainline'); assert.equal(node?.kind, 'mainline');
s.trays.set('ahead', { s.trays.set('ahead', {
@@ -1511,3 +1516,69 @@ describe('an Extra starts where the player puts it (Gitea#4)', () => {
assert.equal(check(s, 0, at), 'NO_EXTRA_PENDING'); assert.equal(check(s, 0, at), 'NO_EXTRA_PENDING');
}); });
}); });
// ---------------------------------------------------------------------------
describe('§8.1 counts only trains AHEAD of the one departing (Gitea#26)', () => {
/**
* REPORTED from playtesting v0.8.0.9: two westbound Extras, X15 at an Office and X18 still crossing
* the card to its EAST. The Superintendent was asked to rule on X15 against X18 — a train behind it —
* and holding X15 kept the Whistle Post's only A/D track full, so X18 arrived into it and was
* destroyed. Reproduced by replaying the exported save; the positions below are that situation in a
* one-seat Division, where every Office is a Whistle Post and the Subdivision spans them all.
*/
const setup = (occupant: { direction: 'east' | 'west'; side: 'east' | 'west'; number: number }) => {
const s = game(7, { days: 5 });
const area = areaOf(s, 0);
const office = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
s.trays.set('departing', {
id: 'departing', trainNumber: 15, trainIsExtra: true, engineAt: 0, consist: [],
direction: 'west', facing: 'w',
position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
});
area.adOccupancy.push('departing');
const card = s.division.nodes.findIndex((n, i) =>
n.kind === 'mainline' && (occupant.side === 'east' ? i > office : i < office));
const node = s.division.nodes[card];
assert.equal(node?.kind, 'mainline');
s.trays.set('other', {
id: 'other', trainNumber: occupant.number, trainIsExtra: true, engineAt: 0, consist: [],
direction: occupant.direction, facing: occupant.direction === 'east' ? 'e' : 'w',
position: { at: 'mainline', index: card }, movesUsed: 0,
});
if (node?.kind === 'mainline') {
node.transits.push({ tray: 'other', stagesRemaining: 2, stagesTotal: 2, direction: occupant.direction });
}
s.clock.phase = 'mainline';
return s;
};
it('does not put a same-direction train BEHIND the departing one to the Superintendent', () => {
const s = setup({ direction: 'west', side: 'east', number: 18 });
const r = advance(s);
assert.ok(!r.events.some((e) => e.type === 'clearanceRequested'), 'a train behind was put to the Superintendent');
assert.ok(
r.events.some((e) => e.type === 'trainHighballed' && e.trainNumber === 15),
'the departing train did not highball with nothing ahead of it',
);
assert.ok(!r.events.some((e) => e.type === 'trainsDestroyed'), 'a train was destroyed');
});
it('does not bar a departure over an opposite-direction train BEHIND it, which is moving away', () => {
const s = setup({ direction: 'east', side: 'east', number: 18 });
const r = advance(s);
assert.ok(
r.events.some((e) => e.type === 'trainHighballed' && e.trainNumber === 15),
'a train moving away behind it held the departure',
);
});
it('still puts a same-direction train AHEAD to the Superintendent', () => {
const s = setup({ direction: 'west', side: 'west', number: 18 });
const r = advance(s);
assert.ok(
r.events.some((e) => e.type === 'clearanceRequested' && e.trainId === 'departing'),
'a train the departing one would follow was not put to the Superintendent',
);
});
});
+59 -1
View File
@@ -159,6 +159,60 @@ describe('Local Operations: drawing (§6.2)', () => {
assert.notEqual(s.decks.departments[1]![0], target, 'refilled with the same card'); assert.notEqual(s.decks.departments[1]![0], target, 'refilled with the same card');
}); });
it('a PLAYED timetabled train never comes back, but a discarded one does — Gitea#23', () => {
/**
* Jesse's ruling, 2026-09-10: *"Once you've played a regularly scheduled train and it's in the
* salvage deck, that train is already on the timetable. It does not make sense to put that back
* into a reshuffled home deck to get played again. By contrast, a regularly scheduled train
* that's in a discard pile could potentially get reused later, and so should have that
* capability. Extras run one time and then they're done — if they are in the Salvage deck, they
* should get shuffled back in so that they could get run again."*
*
* So the test is WHERE the card is, not only what it is: the same card is spent in the Salvage
* Yard and still runnable in a Department. That is what this pins, because it is the kind of rule
* a later tidy-up would happily "simplify" into filtering by card kind everywhere.
*/
const s = game();
const kindOfCard = (id: string): string => s.cards.get(id)?.kind.kind ?? '?';
const pool = [...s.decks.homeOffice];
const trains = pool.filter((id) => kindOfCard(id) === 'timetabledTrain');
const extras = pool.filter((id) => kindOfCard(id) === 'extraTrain');
const others = pool.filter((id) => !['timetabledTrain', 'extraTrain'].includes(kindOfCard(id)));
assert.ok(trains.length >= 2 && extras.length >= 1 && others.length >= 5, 'the deal lacks the cards this needs');
const spentTrain = trains[0]!; // played: in the Salvage Yard, its slot taken
const discardedTrain = trains[1]!; // never played: sitting in a Department
const playedExtra = extras[0]!; // a single run, free to run again
s.decks.salvageYard = [spentTrain, playedExtra, ...others.slice(0, 3)];
s.decks.departments = [[discardedTrain], [others[3]!], [others[4]!]];
s.decks.homeOffice = [others[5]!];
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const r = applyIntent(s, 0, { type: 'draw.fromHomeOffice' });
assert.ok(r.ok);
assert.ok(r.events.some((e) => e.type === 'deckReshuffled'), 'no reshuffle was emitted');
const recovered = new Set([...s.decks.homeOffice, ...s.decks.departments.flat()]);
const hands = new Set([...s.decks.hands.values()].flat());
// THE RULING, both halves.
assert.ok(!recovered.has(spentTrain), 'a played timetabled train was shuffled back in');
assert.ok(!hands.has(spentTrain), 'a played timetabled train was dealt back into a hand');
assert.ok(
s.decks.salvageYard.includes(spentTrain),
'a played timetabled train should stay in the Salvage Yard, not vanish',
);
assert.ok(
recovered.has(discardedTrain) || hands.has(discardedTrain),
'a DISCARDED timetabled train must come back — it was never played, so its slot is open',
);
assert.ok(
recovered.has(playedExtra) || hands.has(playedExtra),
'a played Extra must come back — an Extra is one run, not a standing slot',
);
});
it('reshuffles the Salvage Yard and Departments back in when the deck runs out', () => { it('reshuffles the Salvage Yard and Departments back in when the deck runs out', () => {
// §6.2 — "If drawing a card has depleted the Home Office deck, immediately collect all cards // §6.2 — "If drawing a card has depleted the Home Office deck, immediately collect all cards
// from the Salvage Yard and three Department decks, reshuffle, and reestablish the Home Office // from the Salvage Yard and three Department decks, reshuffle, and reestablish the Home Office
@@ -182,7 +236,11 @@ describe('Local Operations: drawing (§6.2)', () => {
assert.ok(r.ok); assert.ok(r.ok);
assert.ok(r.events.some((e) => e.type === 'deckReshuffled'), 'no reshuffle was emitted'); assert.ok(r.events.some((e) => e.type === 'deckReshuffled'), 'no reshuffle was emitted');
assert.equal(s.decks.salvageYard.length, 0, 'the Salvage Yard must be swept'); // Swept EXCEPT the trains whose slots are already on the timetable — see the ruling test below.
assert.ok(
s.decks.salvageYard.every((id) => s.cards.get(id)?.kind.kind === 'timetabledTrain'),
'the Salvage Yard must be swept apart from spent timetabled trains',
);
assert.ok(s.decks.homeOffice.length > 0, 'the deck must be re-established'); assert.ok(s.decks.homeOffice.length > 0, 'the deck must be re-established');
assert.ok( assert.ok(
s.decks.departments.every((p) => p.length === 1), s.decks.departments.every((p) => p.length === 1),
+73 -5
View File
@@ -14,7 +14,7 @@ import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { DWELL, MAX_PACE, dwellFor, dwellForStep, kindOf, watchableCount } from '../src/sim/pacing.ts'; import { DWELL, MAX_PACE, PACE_LEVELS, dwellFor, dwellForStep, kindOf, watchableCount } from '../src/sim/pacing.ts';
import type { StepKind } from '../src/sim/pacing.ts'; import type { StepKind } from '../src/sim/pacing.ts';
import type { Intent } from '../src/engine/intents.ts'; import type { Intent } from '../src/engine/intents.ts';
@@ -52,7 +52,7 @@ describe('pacing — dwell by kind', () => {
assert.equal(kindOf('switch.end'), 'bookkeeping'); assert.equal(kindOf('switch.end'), 'bookkeeping');
/** /**
* `localOps.choose` IS AN ANNOUNCEMENT, not bookkeeping — moved 2026-09-09 after the first real * `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 * everything that follows, and at zero dwell a bot's turn began with no sign of what it was about
* to do. * to do.
*/ */
@@ -120,6 +120,37 @@ describe('pacing — dwell by kind', () => {
assert.equal(watchableCount(queue, 0), 0, 'with animation off, nothing is behind'); assert.equal(watchableCount(queue, 0), 0, 'with animation off, nothing is behind');
}); });
it('offers speeds a player actually reached for, and none the code would clamp', () => {
/**
* Jesse played a whole game believing he was at 7× and was in fact at 1×: `?pace=` shipped as the
* only lever, and `index.html`'s doors are `play.html?lobby` / `play.html?solitaire`, so arriving
* from the splash REPLACES the query string. Hence a real control on the play screen, and hence
* this ladder — which must reach the speeds people ask for and must not offer one that
* `dwellFor` would silently clamp.
*/
assert.equal(PACE_LEVELS[0], 0, 'off must be the first rung — #18 wants it turned off');
assert.ok(PACE_LEVELS.includes(1), 'the default must be on the ladder');
assert.ok(PACE_LEVELS.includes(7), '7x was asked for by name');
/**
* The ceiling is not theoretical. Jesse played at 10× — the top of the ladder as it then was —
* and called it "still a bit fast, but followable", so the ladder has to go past the speed
* somebody actually reached for and found insufficient.
*/
assert.ok(PACE_LEVELS.some((p) => p > 10), 'the ladder must go beyond the speed that was too fast');
for (const p of PACE_LEVELS) {
assert.ok(p <= MAX_PACE, `${p}x is past MAX_PACE, so the control would lie about it`);
assert.equal(dwellFor('switch.move', p), Math.round(DWELL.switching * p));
}
// Strictly increasing, so stepping the control always changes the speed.
for (let i = 1; i < PACE_LEVELS.length; i++) {
assert.ok(PACE_LEVELS[i]! > PACE_LEVELS[i - 1]!, 'the ladder must be strictly increasing');
}
// The slowest rung has to be slow enough to be worth having: six switching moves at the top of
// the ladder is a full minute, which is the "watch them struggle" case.
const slowest = dwellFor('switch.move', PACE_LEVELS[PACE_LEVELS.length - 1]!) * 6;
assert.ok(slowest >= 60_000, `the slowest a switching turn can be watched is ${slowest}ms`);
});
it('a silent step beats only when the clock turns over — TODO #18', () => { it('a silent step beats only when the clock turns over — TODO #18', () => {
/** /**
* Both obvious rules were wrong, so both are pinned. "No narration, no dwell" flashed past * Both obvious rules were wrong, so both are pinned. "No narration, no dwell" flashed past
@@ -127,18 +158,55 @@ describe('pacing — dwell by kind', () => {
* that changed the board" beat on every turn hand-off — `submit()` steps `advance()` about 4.6 * that changed the board" beat on every turn hand-off — `submit()` steps `advance()` about 4.6
* times per intent — which came to a quarter of an hour a game. * times per intent — which came to a quarter of an hour a game.
*/ */
const silent = { cause: 'phase' as const, lines: [] as string[] }; const silent = { cause: 'phase' as const, player: null, lines: [] as string[] };
assert.equal(dwellForStep({ ...silent, frame: { table: { actor: 2 } } }), 0, 'a turn hand-off shows nothing'); assert.equal(dwellForStep({ ...silent, frame: { table: { actor: 2 } } }), 0, 'a turn hand-off shows nothing');
assert.equal(dwellForStep({ ...silent, frame: { table: {} } }), 0, 'a step that changed nothing shows nothing'); assert.equal(dwellForStep({ ...silent, frame: { table: {} } }), 0, 'a step that changed nothing shows nothing');
assert.equal(dwellForStep({ ...silent, frame: { table: { phase: 'mainline' } } }), DWELL.phase); assert.equal(dwellForStep({ ...silent, frame: { table: { phase: 'mainline' } } }), DWELL.phase);
assert.equal(dwellForStep({ ...silent, frame: { table: { stage: 4 } } }), DWELL.phase); assert.equal(dwellForStep({ ...silent, frame: { table: { stage: 4 } } }), DWELL.phase);
// Narration always earns the dwell of whatever caused it, clock or no clock. // Narration always earns the dwell of whatever caused it, clock or no clock.
assert.equal( assert.equal(
dwellForStep({ cause: 'switch.move', lines: ['moved'], frame: { table: {} } }), dwellForStep({ cause: 'switch.move', player: 1, lines: ['moved'], frame: { table: {} } }),
DWELL.switching, DWELL.switching,
); );
}); });
it('the speed control stretches the clock at a THIRD of the rate it stretches people', () => {
/**
* Two complaints, one from each direction, and the answer is between them.
*
* v0.8.0.3, from a 5× game: *"after my turn … I'm still subject to that same delay before it
* moves on. That makes no sense."* — phases were scaling with everything else and walling off a
* player's own turn. So they were pinned at their tabled beat.
*
* v0.8.0.7, from a 10× game: *"phases displayed on the upper line go by too quickly still.
* Should be 4 times as long — at a guess."* — pinned was too short to read the caption.
*
* Damped scaling satisfies both: 1× unchanged, 10× lands exactly on the four-times guess, and
* the cost stays bounded because phase beats cluster rather than accumulate.
*/
const phase = { cause: 'phase' as const, player: null, lines: ['New Train'], frame: { table: { phase: 'newTrain' } } };
const theirs = { cause: 'switch.move' as const, player: 1, lines: ['moved'], frame: { table: {} } };
assert.equal(dwellForStep(phase, 1), DWELL.phase, '1x must be exactly the tabled beat');
assert.equal(dwellForStep(phase, 10), DWELL.phase * 4, '10x must be four times it, as asked for');
for (const pace of [2, 3, 5, 7, 10, 15, 20]) {
const p = dwellForStep(phase, pace);
const t = dwellForStep(theirs, pace);
assert.ok(p > DWELL.phase, `a phase must grow at ${pace}x`);
assert.ok(
p < DWELL.phase * pace,
`a phase must grow SLOWER than the multiplier at ${pace}x, or the clock walls off the turn`,
);
assert.ok(t > p, `somebody's move must still outlast a phase beat at ${pace}x`);
}
// Off still means off, for the clock as much as for anybody's move; and below 1x the clock
// follows the multiplier straight, because "faster" should mean everything.
assert.equal(dwellForStep(phase, 0), 0);
assert.equal(dwellForStep(theirs, 0), 0);
assert.equal(dwellForStep(phase, 0.5), DWELL.phase * 0.5);
});
it('a real switching turn is watchable in a few seconds, not tens of them', () => { it('a real switching turn is watchable in a few seconds, not tens of them', () => {
// Six moves is the engine's cap per crew ("N of 6 Moves left"), so this is the worst ordinary // Six moves is the engine's cap per crew ("N of 6 Moves left"), so this is the worst ordinary
// case for one crew: the announcement, six moves, and an end that shows nothing. // case for one crew: the announcement, six moves, and an end that shows nothing.
@@ -155,7 +223,7 @@ describe('pacing — dwell by kind', () => {
it("a bot's ordinary turn is followable, which is what the first real play was not", () => { 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 * *"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 * 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 * 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 { applyIntent } from '../src/engine/apply.ts';
import { currentActorOfState, publicSnapshot } from '../src/sim/view.ts'; import { currentActorOfState, publicSnapshot } from '../src/sim/view.ts';
import type { PublicFrame } from '../src/sim/view.ts'; import type { PublicFrame } from '../src/sim/view.ts';
import { applyPublicDelta, deltaPublicFrame } from '../src/sim/public-delta.ts'; import { applyPublicDelta, changedPiles, deltaPublicFrame } from '../src/sim/public-delta.ts';
const config: GameConfig = { const config: GameConfig = {
mode: 'competitive', mode: 'competitive',
@@ -184,3 +184,111 @@ describe('public frame delta', () => {
}); });
type PublicIndexed = { seat: number; before: PublicFrame; after: PublicFrame }; type PublicIndexed = { seat: number; before: PublicFrame; after: PublicFrame };
describe('which piles a step moved', () => {
/**
* MEASURED FROM REAL PLAY, then pinned. The table in `changedPiles` claims what each action moves,
* and a claim in a comment is worth nothing unless something checks it — so this drives real games
* and asserts the mapping holds, action by action.
*/
it('maps each action to the piles it actually touches', () => {
const seen = new Map<string, Set<string>>();
/**
* TWO PASSES, because a single driver cannot reach every case. Left to itself the bot almost
* never takes a Department card, and a driver that prefers one then never draws from the deck —
* so each preference is played out separately and the assertions below require BOTH to have
* been observed rather than passing on whichever happened to occur.
*/
for (const prefer of ['draw.fromDepartment', 'draw.fromHomeOffice'] as const) {
for (const seed of [1917398, 191056, 4242]) {
const s = newState(seed);
for (let i = 0; i < 400; i++) {
const actor = currentActorOfState(s);
if (actor === null) break;
const options = legalActions(s, actor);
if (options.length === 0) break;
const chosen =
options.find((o) => o.type === prefer) ??
options.find((o) => o.type === 'card.discard') ??
options[i % options.length]!;
const before = publicSnapshot(s);
const r = applyIntent(s, actor, chosen);
if (!r.ok) break;
pump(s);
const piles = changedPiles(before, publicSnapshot(s)).map((p) => p.replace(/dept\d/, 'dept'));
if (!seen.has(chosen.type)) seen.set(chosen.type, new Set());
for (const p of piles) seen.get(chosen.type)!.add(p);
if (piles.length === 0) seen.get(chosen.type)!.add('(none)');
}
}
}
const of = (t: string): Set<string> => seen.get(t) ?? new Set();
// NOT VACUOUS: the four cases the mapping is actually about must all have happened.
for (const needed of ['draw.fromHomeOffice', 'draw.fromDepartment', 'card.discard', 'card.play']) {
assert.ok(of(needed).size > 0, `${needed} never occurred, so its rule proved nothing`);
}
// A HOME OFFICE DRAW MOVES THE COUNT AND NOTHING ELSE ON A PILE. The card is private; the deck
// getting shorter is not, and it is the only thing a watcher can be shown.
assert.deepEqual([...of('draw.fromHomeOffice')].sort(), ['home']);
// A DEPARTMENT DRAW touches that Department, and sometimes the deck too — the pile refills from
// it. Both are public, so both may light.
for (const p of of('draw.fromDepartment')) {
assert.ok(p === 'dept' || p === 'home', `a Department draw moved "${p}"`);
}
assert.ok(of('draw.fromDepartment').has('dept'), 'a Department draw must light its Department');
// A DISCARD lands face up on a Department, and which one is public.
assert.deepEqual([...of('card.discard')].sort(), ['dept']);
// A PLAYED CARD that does not stay on the board lands face up in the Salvage Yard.
assert.ok(of('card.play').has('salvage'), 'a played card must be able to light the Salvage Yard');
// ENDING A PHASE moves no card anywhere, so nothing should light for it.
for (const quiet of ['draw.end', 'loadUnload.end', 'switch.end', 'localOps.choose']) {
if (of(quiet).size > 0) assert.deepEqual([...of(quiet)], ['(none)'], `${quiet} lit a pile`);
}
});
it('lights nothing without a previous frame to compare against', () => {
// A reset has nothing to have watched arriving, so nothing on it is lit.
const s = newState(4242);
assert.deepEqual(changedPiles(null, publicSnapshot(s)), []);
});
});
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');
});
});
+3
View File
@@ -524,6 +524,9 @@ describe('#91 — nothing private survives serialisation, in any state', () => {
// The rules the game was dealt under, and the score. // The rules the game was dealt under, and the score.
'houseRules', 'mode', 'optionalRules', 'days', 'minCombinedRevenue', 'houseRules', 'mode', 'optionalRules', 'days', 'minCombinedRevenue',
'maxCollisionsPerDay', 'maxCollisionsTotal', 'collisionsToday', 'collisionsTotal', 'maxCollisionsPerDay', 'maxCollisionsTotal', 'collisionsToday', 'collisionsTotal',
// What the Day that just ended finished on. Public for the same reason the running counts are:
// a collision happens on the Mainline in front of everybody.
'collisionsPrevDay',
'status', 'outcome', 'extraDays', 'extensionVotes', 'official', 'tally', 'status', 'outcome', 'extraDays', 'extensionVotes', 'official', 'tally',
// Names, seats, revenue and HAND SIZE — never hand contents. // Names, seats, revenue and HAND SIZE — never hand contents.
'players', 'players',
+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');
});
});
+95 -2
View File
@@ -15,7 +15,7 @@ import { currentActor, newMultiplayerGame, submit } from '../src/web/game.ts';
import { publicSnapshot } from '../src/sim/view.ts'; import { publicSnapshot } from '../src/sim/view.ts';
import { takeSteps } from '../src/sim/display-step.ts'; import { takeSteps } from '../src/sim/display-step.ts';
import type { DisplayStep } from '../src/sim/display-step.ts'; import type { DisplayStep } from '../src/sim/display-step.ts';
import { createStepQueue } from '../src/web/step-queue.ts'; import { actorOnScreen, createStepQueue } from '../src/web/step-queue.ts';
import { DWELL } from '../src/sim/pacing.ts'; import { DWELL } from '../src/sim/pacing.ts';
const config: GameConfig = { const config: GameConfig = {
@@ -179,7 +179,7 @@ describe('the step queue', () => {
* REGRESSION. `busy()` was `pending.length > 0`, so the instant the final step of a burst was * 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 * 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 * 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". * 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 * The panel follows `busy()`, so this is the property that keeps somebody else's board on screen
@@ -251,6 +251,25 @@ describe('the step queue', () => {
assert.ok(q.showing() !== null, 'the caption should survive a reset'); assert.ok(q.showing() !== null, 'the caption should survive a reset');
}); });
it('can always be emptied, so a player is never stranded behind it', () => {
/**
* "Your Move" is put away while the board is catching up (v0.8.0.6), which makes `busy()` the
* thing standing between a player and their own turn. So the ways it can be cleared matter more
* than they did: `skip()` must always work, from any state, including one where the clock has
* never advanced — which is exactly the situation a page with no `requestAnimationFrame` is in,
* and how this was found.
*/
const { steps, final } = realSteps(1917398, 400);
const q = createStepQueue();
q.reset(baseline(1917398));
q.push(steps);
// Never advanced at all: no frame has been shown, and the queue is full.
assert.equal(q.busy(), true);
assert.equal(q.skip(), true, 'a never-advanced queue must still be skippable');
assert.equal(q.busy(), false, 'and must be idle afterwards, or the player stays locked out');
assert.deepEqual(q.current(), final);
});
it('draws nothing before a reset has arrived', () => { it('draws nothing before a reset has arrived', () => {
const q = createStepQueue(); const q = createStepQueue();
assert.equal(q.current(), null); assert.equal(q.current(), null);
@@ -259,3 +278,77 @@ describe('the step queue', () => {
assert.equal(q.showing(), null); assert.equal(q.showing(), null);
}); });
}); });
describe('whose move the screen is showing (Gitea#25)', () => {
const step = (player: number | null) => ({ player }) as DisplayStep;
const queue = (behind: number, busy: boolean, showing: DisplayStep | null) => ({
behind: () => behind,
busy: () => busy,
showing: () => showing,
});
it('names the live actor once the board has caught up', () => {
assert.deepEqual(actorOnScreen(queue(0, false, step(2)), 0), { actor: 0, replaying: false });
});
it('names the player of the step on screen while the board is behind — not the live actor', () => {
// One human (seat 0) against bots: the live game already waits on seat 0 while bot 2's moves replay.
assert.deepEqual(actorOnScreen(queue(3, true, step(2)), 0), { actor: 2, replaying: true });
});
it('keeps naming the last step while it is still on screen, after the counter reaches zero', () => {
assert.deepEqual(actorOnScreen(queue(0, true, step(1)), 0), { actor: 1, replaying: true });
});
it('names nobody for an automatic phase being shown', () => {
assert.deepEqual(actorOnScreen(queue(2, true, step(null)), 0), { actor: null, replaying: true });
});
it('falls back to the live actor before any step has been shown', () => {
assert.deepEqual(actorOnScreen(queue(1, true, null), 3), { actor: 3, replaying: false });
});
});
describe('the log is held back with the board, and a changed card flashes (playtest, 2026-09-15)', () => {
it('owes exactly the lines of the steps not yet shown', () => {
const { steps } = realSteps(1917398, 400);
const q = createStepQueue();
q.reset(baseline(1917398));
assert.equal(q.pendingLines(), 0, 'an empty queue holds nothing back');
q.push(steps);
const owed = steps.reduce((n, s) => n + s.lines.length, 0);
assert.equal(q.pendingLines(), owed, 'every queued step still owes its lines');
// Drive the clock as a render loop would; the debt falls monotonically and ends at nothing.
let now = 0;
let last = owed;
for (let i = 0; i < 20_000 && q.busy(); i++) {
q.advance(now);
const left = q.pendingLines();
assert.ok(left <= last, 'the held-back count grew while the board caught up');
last = left;
now += 50;
}
assert.equal(q.pendingLines(), 0, 'the board caught up but lines were still withheld');
});
it('skipping reveals the whole log at once', () => {
const { steps } = realSteps(1917398, 400);
const q = createStepQueue();
q.reset(baseline(1917398));
q.push(steps);
q.skip();
assert.equal(q.pendingLines(), 0, 'Skip left lines withheld — the history would stay short');
});
it('flashes nothing on an ordinary step', () => {
// Realignment is rare in bot play, so this pins the quiet case: the map must not pulse at random.
const { steps } = realSteps(1917398, 400);
const q = createStepQueue();
q.reset(baseline(1917398));
q.push(steps.slice(0, 5));
q.advance(0);
assert.deepEqual(q.flashing(), [], 'a step that changed no Mainline card flashed one');
});
});
+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', () => { it('splits Revenue into what was earned and what was given back', () => {
// Reconciliation is the real assertion and it holds for any game, earned or not: gained minus // Reconciliation is the real assertion and it holds for any game, earned or not: gained minus
// lost IS the score the engine kept. Seed 42 is named because it is one where Revenue actually // lost IS the score the engine kept. Seed 44 is named because it is one where Revenue actually
// moves in both directions — it earns 1 and gives back 5 to a collision — so the two halves are // moves in both directions — it earns 1 and gives back 5 to a collision — so the two halves are
// being told apart rather than both sitting at zero. // being told apart rather than both sitting at zero.
for (const seed of [1, 7, 42]) { //
// It was seed 42 until v0.8.0.10. That game's collision was the Superintendent holding a train over
// one BEHIND it (Gitea#26); with the ruling gone the collision is too, and seed 42 now earns 5 and
// loses nothing — a better game and a vacuous test. The seed moved, not the assertion.
for (const seed of [1, 7, 44]) {
const { state } = playKeepingEvents(seed); const { state } = playKeepingEvents(seed);
const me = state.tally.byPlayer[0]!; const me = state.tally.byPlayer[0]!;
assert.equal( assert.equal(
@@ -163,10 +167,10 @@ describe('the tally counts every event exactly once (Gitea#16)', () => {
`seed ${seed}: gained minus lost does not reconcile with the score the engine kept`, `seed ${seed}: gained minus lost does not reconcile with the score the engine kept`,
); );
} }
const { state } = playKeepingEvents(42); const { state } = playKeepingEvents(44);
const me = state.tally.byPlayer[0]!; const me = state.tally.byPlayer[0]!;
assert.ok(me.revenueGained > 0, 'seed 42 earned nothing — the gained half is not being counted'); assert.ok(me.revenueGained > 0, 'seed 44 earned nothing — the gained half is not being counted');
assert.ok(me.revenueLost > 0, 'seed 42 lost nothing — the lost half is not being counted'); assert.ok(me.revenueLost > 0, 'seed 44 lost nothing — the lost half is not being counted');
}); });
it('records a Circus set-up as the one-off it is, not as a streak', () => { it('records a Circus set-up as the one-off it is, not as a streak', () => {
+68
View File
@@ -309,3 +309,71 @@ describe('steps reach a seated player — TODO #13', () => {
assert.ok(seen > 0, 'no steps reached a push, so this proved nothing'); assert.ok(seen > 0, 'no steps reached a push, so this proved nothing');
}); });
}); });
describe('the log says who acted, once, and in what capacity (Gitea#30, #31)', () => {
it('never names a player twice — no "Player Jesse player 0 finished …"', async () => {
const { newMultiplayerGame, currentActor, submit } = await import('../src/web/game.ts');
const { legalActions } = await import('../src/engine/legal.ts');
const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']);
for (let i = 0; i < 400; i++) {
const actor = currentActor(game);
if (actor === null) break;
const options = legalActions(game.state, actor);
if (options.length === 0) break;
if (!submit(game, options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
}
assert.ok(game.log.length > 50, 'the game barely ran, so this proved little');
for (const line of game.log) {
// `record()` prefixes the acting player's NAME; a narration that also named them read
// "Player Alice player 0 finished Local Operations" (playtest, 2026-09-15).
assert.doesNotMatch(
line.text,
/\bplayer \d+\b/i,
`a line still carries a bare player index: ${line.text}`,
);
}
});
it('attributes a clearance ruling to the office, not to the seat\'s own turn', async () => {
const { newMultiplayerGame, drain, submit } = await import('../src/web/game.ts');
const { areaOf } = await import('../src/engine/apply.ts');
const game = newMultiplayerGame(7, config, ['Alice', 'Bob', 'Carol']);
const s = game.state;
const area = areaOf(s, 0);
// A westbound train at seat 0's Office, and another westbound AHEAD of it — west of the Office —
// which is §8.1's fourth condition and the Superintendent's to rule on (see Gitea#26).
s.trays.set('departing', {
id: 'departing', trainNumber: 15, trainIsExtra: true, engineAt: 0, consist: [],
direction: 'west', facing: 'w',
position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
} as never);
area.adOccupancy.push('departing');
const office = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
const card = s.division.nodes.findIndex((n, i) => i < office && n.kind === 'mainline');
const node = s.division.nodes[card];
assert.equal(node?.kind, 'mainline');
s.trays.set('ahead', {
id: 'ahead', trainNumber: 9, trainIsExtra: false, engineAt: 0, consist: [],
direction: 'west', facing: 'w', position: { at: 'mainline', index: card }, movesUsed: 0,
} as never);
if (node?.kind === 'mainline') {
node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
}
s.clock.phase = 'mainline';
drain(game);
assert.equal(s.clock.pendingDecision?.kind, 'clearance', 'no ruling was called for, so nothing was tested');
const before = game.log.length;
assert.ok(submit(game, { type: 'mainline.clearance', allow: false }, s.clock.superintendent));
const said = game.log.slice(before).map((l) => l.text);
assert.ok(
said.some((text) => text.startsWith('Superintendent Player ')),
`a ruling did not read as the office's: ${said.join(' | ')}`,
);
});
});
+103 -1
View File
@@ -23,7 +23,7 @@ import { variantsFor } from '../src/engine/track.ts';
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts'; import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
import type { DivisionView } from '../src/sim/view.ts'; import type { DivisionView } from '../src/sim/view.ts';
import { ENHANCEMENT_RULES, STAGES_PER_DAY } from '../src/engine/content.ts'; import { ENHANCEMENT_RULES, STAGES_PER_DAY } from '../src/engine/content.ts';
import { dayEndHtml, facilitiesHtml, resultsHtml, timetableHtml } from '../src/web/panels.ts'; import { dayEndHtml, facilitiesHtml, pilesHtml, resultsHtml, timetableHtml } from '../src/web/panels.ts';
import { turnChartHtml } from '../src/sim/turnchart.ts'; import { turnChartHtml } from '../src/sim/turnchart.ts';
import { fieldSelectors } from '../src/web/settings-form.ts'; import { fieldSelectors } from '../src/web/settings-form.ts';
import { record, renderHtml } from '../src/sim/replay.ts'; import { record, renderHtml } from '../src/sim/replay.ts';
@@ -3614,6 +3614,108 @@ describe('the Day rolling over says so (Gitea#10)', () => {
assert.ok(html.includes('3 Days left'), `the Days remaining are wrong:\n${html}`); assert.ok(html.includes('3 Days left'), `the Days remaining are wrong:\n${html}`);
}); });
it('draws the Home Office deck, face down, with its count', () => {
/**
* `f.deck` has carried the face-down count since the Frame existed and NOTHING drew it — the
* exact display gap `test/display-gaps.test.ts` sweeps for, surviving in the panel that draws
* every other pile. Asked for by Jesse 2026-09-10, who also wanted somewhere for a draw to
* flash: taking a card off this deck is the commonest move nobody can see.
*/
const s = createEngineGame({
id: 'piles',
seed: 5,
config: {
mode: 'competitive', days: 5, minCombinedRevenue: 60,
maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['Joe', 'Bot 1'],
});
const f = snapshot(s, [], null);
assert.ok(f.deck > 0, 'the deal should leave cards in the Home Office deck');
const html = pilesHtml(f);
assert.ok(html.includes('Home Office'), `no Home Office pile:\n${html}`);
assert.ok(html.includes(`>${f.deck}<`), 'the face-down count is not shown');
// Face down means the card slot must NOT name a card — that is the whole point of the pile.
assert.ok(html.includes('facedown'), 'the Home Office pile is not marked face down');
assert.ok(html.includes('face down'), 'the card slot should say so rather than naming a card');
// It comes first: a card travels out of here, then onto a Department or the Salvage Yard.
assert.ok(
html.indexOf('Home Office') < html.indexOf('Dept 1'),
'the draw deck should be read before the piles cards land on',
);
});
it('lights only the pile a watched move touched', () => {
const s = createEngineGame({
id: 'piles2',
seed: 5,
config: {
mode: 'competitive', days: 5, minCombinedRevenue: 60,
maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['Joe', 'Bot 1'],
});
const f = snapshot(s, [], null);
assert.equal(pilesHtml(f).includes('pilelit'), false, 'nothing is lit when nothing was watched');
const home = pilesHtml(f, ['home']);
assert.equal((home.match(/pilelit/g) ?? []).length, 1, 'exactly one pile should light');
assert.ok(
home.indexOf('pilelit') < home.indexOf('Dept 1'),
'a Home Office draw must light the Home Office pile, not a Department',
);
const dept2 = pilesHtml(f, ['dept1']);
assert.equal((dept2.match(/pilelit/g) ?? []).length, 1);
assert.ok(dept2.indexOf('Dept 2') > dept2.indexOf('Dept 1'), 'order sanity');
// Two piles can move at once — a Department draw that refills from the deck.
assert.equal((pilesHtml(f, ['home', 'dept0']).match(/pilelit/g) ?? []).length, 2);
});
it('reports the ENDED Day\'s collisions, not the fresh Day\'s zero', () => {
/**
* Jesse, 2026-09-09, at the end of a Day 1 with two collisions in it: *"It shows a total of two
* collisions, but zero today. Since we just finished day one, that does seem to be a
* contradiction."*
*
* The cause is a one-line ordering fact: `advance.ts` increments the Day and then zeroes
* `collisionsToday`, and this dialog is drawn from the frame whose Day went UP — so it read the
* fresh Day's zero and printed it beside a running total that could not agree with it. The count
* is captured at the rollover now, and the dialog names the Day rather than saying "today".
*/
const s = createEngineGame({
id: 'collide',
seed: 5,
config: {
mode: 'competitive',
days: 5,
minCombinedRevenue: 60,
maxCollisionsPerDay: 3,
maxCollisionsTotal: 10,
pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['Joe', 'Bot 1'],
});
// The state as the rollover out of Day 1 leaves it: two collisions happened, `today` is reset.
s.clock.day = 2;
s.collisionsPrevDay = 2;
s.collisionsToday = 0;
s.collisionsTotal = 2;
const html = dayEndHtml(snapshot(s, [], null));
assert.ok(html.includes('Day 1 has ended'), `wrong Day named:\n${html}`);
assert.ok(html.includes('<b>2</b> on Day 1'), `the ended Day's collisions are wrong:\n${html}`);
assert.ok(html.includes('<b>2</b> in all'), `the running total is wrong:\n${html}`);
assert.doesNotMatch(html, /<b>0<\/b> today/, `still reporting the fresh Day's zero:\n${html}`);
// The contradiction itself: a Day-end dialog must never claim fewer in all than on that Day.
assert.doesNotMatch(html, /<b>0<\/b> on Day 1/, 'reported no collisions on a Day that had two');
});
it('counts the last Day as the last Day rather than promising more', () => { it('counts the last Day as the last Day rather than promising more', () => {
const html = dayEndHtml(frameAt(6)); const html = dayEndHtml(frameAt(6));
assert.ok(html.includes('Day 5 has ended'), 'the final Day is misnamed'); assert.ok(html.includes('Day 5 has ended'), 'the final Day is misnamed');