Builds "The Roster Pass" / "Two Trains, One Card" (station display for multiple trains at one Office). The Office is the one square where more than one train may legally stand at once (one per A/D track), and CellView.train had room for exactly one — a second train at a busy Station was counted in the old A/D pips and never drawn. CellView.train -> CellView.trains: TrainView[], seat-filtered and collecting every match rather than the first (fixes a latent cross-district leak in the process: trainOnCard never checked seat). The A/D pips are replaced with one roster chip per A/D track, always, free or occupied; clicking a chip sets selectedCrew, wiring the board and the action panel to the same value. standingWest moves from CrewTray to TrackCard: two trays sharing one Office card need one shared split, not one each, and there is no such thing as "west of one particular A/D track". No save migration — Save replays through the engine — and a stale value on an emptied card is inert because nothing reads a split with no train standing there. The Division map's Office cell is now sized by A/D capacity rather than occupancy, so it holds still as trains arrive and leave; chips lay into fixed slots instead of centre-spreading onto the Limits cards either side. 584 tests, 0 failures. Verified end-to-end against the built app and a direct render of a 4-train Terminal (screenshotted). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SAt2YCXgd5qCjBcF2x34aK
3537 lines
230 KiB
Markdown
3537 lines
230 KiB
Markdown
# Changelog
|
||
|
||
Detail behind each commit. Commit messages stay high level; the reasoning, the measurements, and
|
||
the things that turned out to be wrong live here.
|
||
|
||
Measured figures are 100 solitaire Standard games with the developer bot unless stated otherwise.
|
||
The target is 20 Revenue over 5 Days.
|
||
|
||
## Versions
|
||
|
||
Every commit carries a bump, decided with Jesse rather than assumed:
|
||
|
||
- **third digit** — bug fixes to what is already there.
|
||
- **second digit** — a new set of features.
|
||
- **1.0** — the first release we think is solid enough to call one.
|
||
|
||
The number lives in `package.json` and nowhere else; `scripts/build-web.ts` stamps it into every
|
||
page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identified from the page itself.
|
||
|
||
---
|
||
|
||
## 0.4.8 — 2026-08-19
|
||
|
||
Four reports from the same game, all about squares: which ones a card may go on, which one a button
|
||
in the action list means, when there is more than one legal road to the same square which one a
|
||
train actually takes, and — when more than one train is standing at the same Office — which is
|
||
which.
|
||
|
||
### The Limits bound the district, not just the Running Track
|
||
|
||
**Reported:**
|
||
|
||
> "Sidings should not be allowed to be built outside the limits."
|
||
|
||
The Limits sign "denotes the limit of your control area" (§2.1) and §3 defines Secondary Track as
|
||
"all tracks **in your limits** that are not the Running Track" — but the bound was only ever enforced
|
||
on the Running Track row itself, with a comment in `canPlaceAt` stating outright that "the district
|
||
below the Running Track is unbounded". So a siding could run east past a player's own sign, take
|
||
industries with it, and put cars on track that §8.1 and §10 do not consider his territory at all: the
|
||
rules reason about "the track between the train and the Limits", and running past another player's
|
||
Limits is what makes a collision his fault.
|
||
|
||
`withinLimits` is now the district's rule at **every row**, and a Facility is bounded by it too —
|
||
§11.2 is explicit that "Facility cards carry their own rails: placing a Facility places track". The
|
||
refusal is its own code, `OUTSIDE_LIMITS`, rather than `NOT_CONNECTED`, for the same reason
|
||
`BREAKS_RUNNING_TRACK` is: the card would join perfectly well, and is refused for a different reason.
|
||
|
||
**Inclusive of the sign's own column**, which is the half that keeps the game playable. Jesse's call.
|
||
The sign stands *on* the boundary rather than beyond it, and a district opens with signs at ±1 around
|
||
the Office — so the strict reading would leave exactly one buildable column and break §11.3's promise
|
||
that both Secondary rows, and the nine-spot Modifier neighbourhood, are usable from the first Stage.
|
||
A siding may run under the sign; nothing may go past it.
|
||
|
||
**What it costs the bot: nothing measurable.** Out-of-limits building was rare for it to begin with —
|
||
5 cards across 100 games, in 4 of them, never more than two columns over — and paired over 200 seeds
|
||
the bound moved 4 games and −0.015 Revenue (t = −0.26). It is a human building deliberately who hits
|
||
this, which is exactly how it was found.
|
||
|
||
### The nine spots really are nine — the four diagonals were legal and unofferable
|
||
|
||
**Reported:**
|
||
|
||
> "Modifiers should be able to go on any diagonal — we were unable to place it to the south-east."
|
||
|
||
§9 places a Modifier "adjacent to a Facility, on any of the nine nearby spots", and `check` has
|
||
always accepted all eight neighbours. The fault was upstream in `placementCandidates`, which walks
|
||
north, south, east and west from every occupied square: a diagonal square with no *orthogonal*
|
||
occupied neighbour was never generated, so it was never offered. Measured on a facility below the
|
||
Office: `check` says legal at all four diagonals, the menu offered the three orthogonal ones. At a
|
||
stub industry — the case in the report — every diagonal is in that position.
|
||
|
||
Generated in a **second pass, only around a Facility**. Track has to *join* and a diagonal shares no
|
||
edge, so a Modifier is the only card those squares can ever take; generating diagonals around every
|
||
card instead costs 54% more simulation time producing candidates `check` then rejects. Appending
|
||
rather than interleaving leaves the existing candidate order untouched, which matters because the bot
|
||
breaks ties by first-best — measured over 200 seeded games, **0 played differently**, so every figure
|
||
in `TODO.md` still stands.
|
||
|
||
### A Modifier may hang outside the Limits — but not in the Running Track's row
|
||
|
||
Jesse's call, both halves. A Modifier is not track (§9), so unlike a siding it keeps all nine of its
|
||
spots when its host stands at the limit: refusing the outer three would make the card unplayable
|
||
exactly where the district ends. What it may not do is stand in the row the Running Track grows
|
||
along. Inside the Limits that row is always full, so the rule bites only beyond the sign — and that
|
||
is the ground the main extends onto, where a parked Modifier would block the player's own sign from
|
||
moving outward (§2.1) with nothing on screen to warn him. Industries have been barred from the
|
||
Running Track since the "Placed" column was read properly; this is the same rule for the same reason.
|
||
|
||
### The board draws where the district ends
|
||
|
||
Two signs on one row cannot say that nothing may be built outside their columns at *any* row: a
|
||
player looking at open ground beyond a sign had no way to know it was unbuildable until the square
|
||
failed to light up. `officeSvg` now draws a quiet dashed edge down the whole canvas at each limit,
|
||
labelled, and just **outside** the sign's own column so the picture agrees with the rule — a siding
|
||
may run under the sign. It moves outward on its own as the Running Track is extended, which is the
|
||
reward for extending made visible. The Frame carries `limits` for it, resolved server-side like
|
||
everything else a remote client cannot look up (`multiplayer.md` §5).
|
||
|
||
The margin on the viewBox is not cosmetic: the west sign is usually the leftmost card on the canvas,
|
||
so the line outside its column lands at x = −1.5 and simply is not there on a canvas starting at 0.
|
||
Caught by drawing one, not by reading the code.
|
||
|
||
### Hovering an action points at the square it means
|
||
|
||
**Reported:**
|
||
|
||
> "When I have my mouse over the action for a particular square, could the corresponding square in
|
||
> the office area be highlighted to minimize my mistakes of selecting the wrong square — 1,3 versus
|
||
> −1,3? In a solitaire game with Undo it is not fatal. In a multiplayer game that could be a major
|
||
> disaster."
|
||
|
||
The action list is a column of near-identical sentences separated by a coordinate, and the board
|
||
beside it said nothing about which was which. Every action that names a square now carries it as
|
||
`data-square`, and hovering — or focusing, so the keyboard route is not a lesser one — lights that
|
||
square on the board, ghost targets included, which is precisely the moment a player is choosing
|
||
between coordinates.
|
||
|
||
The coordinate rides on the **menu**, resolved from the intent by `coordOf`, rather than being parsed
|
||
back out of the label: a remote client holds no `GameState` and cannot look up where a tray is
|
||
standing, which is the same reason the menu already carries card descriptions. Checked over 12 bot
|
||
games: 870 direct actions name a square, and all 870 carry it.
|
||
|
||
### Two routes to the same square
|
||
|
||
**Reported:**
|
||
|
||
> "There are times when a train can take two different paths to get to a destination. See game at
|
||
> undo 379. Train 11 can go from Eastern limits to the straight at 1,1 two different ways. It can go
|
||
> through the refinery and pick up the tanker on its nose, or it could go straight and then make the
|
||
> curve and skip the refinery and not pick up the car. The player should have the two different
|
||
> options."
|
||
|
||
A district with a passing loop — turnout off the main, curve down to an industry, curve back up —
|
||
offers two legal routes between the same two squares, and `exploreMoves` (`track.ts`) only ever found
|
||
one: it keyed its visited set on destination and entry port, both routes rejoin through the same
|
||
port, and the shorter one won the race before the longer one could be recorded. Which route survived
|
||
was an artifact of search order, not a choice.
|
||
|
||
**Ruling (Jesse, docs/rules/open-questions.md Gap 14):** the player may choose the path. §A.4 says
|
||
"cars **on your track**" — the industry spur is a different track, and declining to enter it is not
|
||
going around the car standing there. The mandatory half still bites in full once a route is chosen:
|
||
every car standing on it couples.
|
||
|
||
**The walk now enumerates every simple route** — a per-path visited set replaces the old global one,
|
||
so a card may be revisited across different routes but never twice within one — capped at 4,000
|
||
frontier nodes so a dense district cannot blow the walk up combinatorially; BFS order means the cap
|
||
loses only the longest routes first. Routes are deduped on **outcome**, not on reaching the square:
|
||
`(destination, entry side, [(origin card, car)…])`, so two routes coupling identical car *types* off
|
||
*different* cards are both offered and each sweeps its own card, while two routes with nothing to
|
||
tell apart collapse to one.
|
||
|
||
**`switch.move` gained an optional `via: GridCoord`** — one intermediate square on the chosen route,
|
||
never the start or the destination. `legal.ts` emits one intent per distinct route with a `via` that
|
||
distinguishes it from its siblings; `via` absent resolves exactly as before (the first route
|
||
enumerated), so every existing save and every bot decision replays identically. Checked against
|
||
`public/replays/*.json` and the full suite: 575 pass, 0 fail, unchanged.
|
||
|
||
Threaded through the label (`describeIntent` finds the route `via` names, so "couples 1 tanker"
|
||
reports the cars *that* route actually lifts), the action-list dedupe (two routes to one square would
|
||
otherwise print the identical button twice), the hover highlight (`data-route` lights every square a
|
||
route runs over, not just its destination), and the history (`trayMoved.via`, when the move was
|
||
ambiguous, names which road the crew took).
|
||
|
||
**Balance impact: none expected, and none measured able to be attributed to this.** Instrumented over
|
||
60 developer-bot games (17,884 destinations enumerated): squares reachable by 2+ distinct paths are
|
||
~1.2% (208), and in every one of those 208 the routes coupled identically — the bot has never yet
|
||
built a district where the discarded route would have mattered. 400 full games (200 developer bot,
|
||
200 random bot) completed 200/200 with no exceptions on the reworked walk. `compare.ts` needs an
|
||
ablation flag to run at all and this is not a bot heuristic, so paired A/B was not applicable here;
|
||
the enumeration-based argument above is the actual evidence.
|
||
|
||
### The Roster Pass — every train at the Office visible
|
||
|
||
**The Office is the one square where more than one train may legally stand at once** — it has
|
||
several A/D tracks, each holding one — and the board drew it as if only one ever could. Two trays
|
||
at a Station couple onto the identical `officeCoord`; `trainOnCard()` returned on the first match
|
||
and `CellView.train` had room for exactly one, so a second train was counted in the old A/D pips
|
||
and never drawn at all. Reported directly, and analysed in "Two Trains, One Card": the pips and the
|
||
picture were reading different fields.
|
||
|
||
**`CellView.train` is now `CellView.trains: TrainView[]`** — every train standing on the card, each
|
||
carrying its own `trayId`. The A/D pips are gone; in their place is one roster chip per A/D track,
|
||
always (`adTracks` of them, not one per train), free tracks reading a dashed, dimmed "free" rather
|
||
than vanishing. The selected train's chip is lit the same amber the crew strip and action buttons
|
||
use, and its consist is the one drawn at the rail — clicking any occupied chip sets `selectedCrew`,
|
||
the same value the "Which train are you switching?" picker already wrote, so the board and the
|
||
action panel drive one value in both directions.
|
||
|
||
**`standingWest` moved from `CrewTray` to `TrackCard`.** The split still means what it always meant
|
||
— west/east of the row of standing cars — and it is still only meaningful while an engine stands on
|
||
the card (an unattended cut has no near or far side; that reasoning did not change). What forced the
|
||
move: two trays sharing one Office card could otherwise hold two different splits of the identical
|
||
row, and there is no such thing as "west of one particular A/D track" — a cut lies west or east of
|
||
the *whole block* of A/D tracks, so there has to be exactly one number for the card to carry. No
|
||
save migration: `Save = {seed, history, rules?}` replays through the engine rather than being
|
||
loaded, and a stale value on an emptied card is inert because nothing reads a split with no train
|
||
standing there.
|
||
|
||
**The Division map stopped drawing trains on their neighbours.** The Office's Running Track cell was
|
||
a fixed 78px regardless of how many A/D tracks it had, so two chips centre-spread wider than the
|
||
cell and spilled onto the Limits cards either side. The cell is now sized by **capacity**
|
||
(`max(78, adTracks·54+12)`), not by how many tracks are occupied — sized by occupancy instead, the
|
||
board would have shifted the East Division Point sideways every time a train arrived or left — and
|
||
chips are laid into fixed slots, one per A/D track, so none can ever overhang the cell.
|
||
|
||
**Verified:** two trays at a Station are both drawn, each with its own chip; a Terminal's four
|
||
tracks all fit; a bare cut with a stale `standingWest` still draws left-aligned; every roster chip's
|
||
x-extent lies inside its Office cell at full occupancy; and a packed replay row from before this
|
||
feature — a lone train object, not an array — rehydrates into a one-train roster rather than
|
||
throwing. 584 tests, 0 failures.
|
||
|
||
### Also
|
||
|
||
- The three published replays were re-recorded. A save is a seed and its intents, so tightening a
|
||
placement rule kills every replay containing one — `harness.test.ts` catches it rather than letting
|
||
them go quietly dead, which has happened twice before. Old saves are not preserved across rule
|
||
changes and are not meant to be yet.
|
||
- `node_modules` was a **tracked symlink pointing at a path that does not exist**, so `tsc` was
|
||
missing and 20 tests failed before any of this was written. Replaced with the real dependencies.
|
||
|
||
## 0.4.7 — 2026-08-19
|
||
|
||
Eight play reports and one design that had been written up and not built. The through-line is the
|
||
switching game: what a card can hold, which end of a train a cut comes off, which way a train meets
|
||
cars standing on the line, and what the board and the log say about all of it.
|
||
|
||
### Track order for standing cars, and the cut you left on your own card
|
||
|
||
**Reported, twice, and it turned out to be one bug wearing two faces:**
|
||
|
||
> "If I put cars off the nose on a given track, and my next move is go forward, I need to couple
|
||
> those cars right back on. If I drop cars off the back and I move back, then I will automatically
|
||
> recouple the cars onto the back of my train. Right after dropping my cars I need to be able to see
|
||
> if those cars are ahead or behind the train."
|
||
|
||
> "When dropping all 4 cars, order was reversed. It worked properly if we dropped cars individually.
|
||
> Also, when adding four cars, again the order was reversed."
|
||
|
||
**The single root cause.** `TrackCard.standing` was a bare array whose doc comment claimed "in track
|
||
order (§A.3)" and which in fact had **no defined orientation at all**. `CrewTray.consist` *is*
|
||
oriented — nose first, relative to `facing` — so every transfer between the two is a conversion that
|
||
nothing performed. §A.3 says what the orientation should be outright: cars "occupy the track, in the
|
||
same order they originally held, **left-to-right**". Left-to-right is west-to-east.
|
||
|
||
So `standing` (and an industry track through `carsOn`) now runs **west to east**, which is the
|
||
board's own orientation rather than whichever train last touched the card. Three consequences, each
|
||
of which was one of the reports:
|
||
|
||
- **Setting out is batch-invariant.** A drop costs no Moves, so one drop of four and four drops of
|
||
one are the same turn played two ways — and they parked three different orders, one of them
|
||
physically impossible (`2+2` gave `reefer tank boxcar hopper`). Successive cuts off the same end
|
||
stack up *towards* the engine, so the insertion point is the train's own place in the row, and all
|
||
three now park identically.
|
||
- **Approaching a cut from either end mirrors.** `couples` was accumulated in path order with no
|
||
reference to the direction of travel, so running onto a parked cut eastbound and westbound gave the
|
||
**identical** consist. It is built nearest-first along the direction of travel now, and reverses on
|
||
its way onto the nose — the farthest car met ends up nose-most, which is what makes a run-around
|
||
worth the Move it costs.
|
||
- **A train no longer drives through its own cut.** The movement walk began at the *neighbour* of the
|
||
start square and never read the start card at all, so a crew could set cars out and pull straight
|
||
away from them in either direction. Coupling is mandatory (§A.4) and your own square is no
|
||
exception: pulling out through the end the cut sits at picks it back up, and the cut counts against
|
||
the four-car limit. Setting out off the end you are *not* leaving by still works, which is the whole
|
||
reason the choice of end is a decision.
|
||
|
||
**`CrewTray.standingWest`** records where a train stands among the cars on its card — a train may set
|
||
out off both ends on one square, so which side a cut is on is not recoverable from the array alone.
|
||
It answers all three questions that needed it: which cut a departing train must couple, which cars are
|
||
ahead of the engine and which behind, and which side of the chip the board draws them on.
|
||
|
||
**On the board.** The cut used to be drawn as one strip along the bottom of the card whether a train
|
||
was there or not, so "are those cars ahead or behind" had no answer in the picture. The row is split
|
||
at the train now — west cars left, east cars right, the engine in the gap — and every car's tooltip
|
||
says *"standing AHEAD of the engine — it would couple onto the nose pulling forward"* or the reverse.
|
||
The history says which end a cut came off, and a move's button distinguishes *"takes your own empty
|
||
boxcar back off this card"* from cars found standing on the line.
|
||
|
||
**The printed-rule interaction, decided.** Trains 3/4 spend a per-location freight budget on setting
|
||
out, so recoupling would have been refused with `FREIGHT_WORKED_HERE` and a legal-looking drop would
|
||
have become silently one-way — same shape for X13's "drop but not pick up" and X22's "empties only".
|
||
Taking your own cut back on the square you are standing on is **undoing the drop**: exempt from those
|
||
restrictions, and the budget is refunded. The alternative — treat it as an ordinary pick-up — never
|
||
strands a train, since backing up stays legal, but it makes a legal-looking move a trap, which is
|
||
exactly what the "why can't I move" panel exists to prevent.
|
||
|
||
**Measured**, 200 paired seeds, developer bot: **-0.55 revenue** (SE 0.15, t = -3.63), 3 seeds better,
|
||
27 worse, 170 identical, with freight revenue 1.11 → 0.56. This is a real cost and it is the bot's,
|
||
not the rule's. The bot's trains run engine-first with every car behind, so at a stub industry it sets
|
||
a car out *between itself and the only way out* — and the correct play is §A.5's "facing point" move,
|
||
shoving the car in ahead of the engine and backing out, which is the same cross-turn planning
|
||
`TODO.md` already records as out of reach of any bot. What the bot could be taught, it was: moves that
|
||
drag its own cut back on are filtered out of its options before any heuristic sees them, which took
|
||
recoupling from **625 of 1,029 set-outs in 60 games to 101 of 677** — and all 101 that remain are the
|
||
stub-industry case above. Read the revenue as a bot measurement, not a balance one.
|
||
|
||
Twenty-one tests in `test/cut-ordering.test.ts`: batch-invariance off both ends at both facings, the
|
||
mirror property, the nearest-first coupling order in both directions, the round trip at every batch
|
||
size, §A.5's trailing-point step 1, the own cut counting against the four-car limit, and the freight
|
||
budget refunded for a 3/4 Express but still charged for a genuine pick-up. The published replays were
|
||
re-recorded twice — legality changed, so bot play changed.
|
||
|
||
### The Superintendent's ruling names the trains it is about
|
||
|
||
**Reported:** the Superintendent could not tell which train he was clearing without hovering the
|
||
button. The §8.1 clearance is the sharpest decision in the game and it was posed as "Superintendent —
|
||
rule on this train", over buttons reading "ALLOW" and "HOLD".
|
||
|
||
Which train is exactly what the ruling turns on, and it was the one thing not said. Two changes, and
|
||
the second is the one that was actually broken:
|
||
|
||
- **The heading asks the question.** The pending decision holds both trays, so it now reads *"may
|
||
Train 6 follow Train 4 onto the same Mainline card?"* instead of naming neither.
|
||
- **The train moved to the FRONT of each button.** They already carried the trains and the
|
||
consequences — but `actionButton` splits a label at the first em-dash and shows only the head, so
|
||
"ALLOW — Train 6 follows Train 4…" put the whole point behind a hover. They read
|
||
*"ALLOW Train 6 to follow Train 4 — onto the same Mainline card, closing up behind it"* and
|
||
*"HOLD Train 6 — it waits where it is, losing the Stage but keeping the line clear"*, so the
|
||
visible half of each button is now the half that decides it.
|
||
|
||
### An industry track holds four cars, like every other card
|
||
|
||
**Fixed:** a crew standing at an industry could set out fewer cars than it was carrying. Reported
|
||
from a playtest at undo 188 — *"we wanted to drop two cars, but were only allowed to drop one."*
|
||
|
||
The industry track was built `length: baseOut + baseIn` cars long, so its capacity for ROLLING STOCK
|
||
was silently its capacity for WORK. A Mine Tipple prints one green box and no red one, so it had
|
||
room for exactly one car and refused the second; the same arithmetic gave a Power Plant one and a
|
||
Freight House two. Nothing in the rules says this. An industry track is ordinary Operating Rail, and
|
||
what actually limits a set-out is the same thing that limits it anywhere else: a consist may not
|
||
exceed four cars, so four is all that can ever be shoved onto a card.
|
||
|
||
`industryTrack.length` is gone rather than corrected. Leaving a number there invites the next reader
|
||
to derive it from something, which is exactly how this happened. `spaceOn` is now one line for every
|
||
card — `MAX_CONSIST - carsOn(card).length` — and `carsOn` picks the industry track by asking whether
|
||
the facility is freight, which is what the length was standing in for.
|
||
|
||
**Also fixed, in the same place:** ordinary track was *unbounded*. It could be piled with five or
|
||
more cars, and once it was, no train could legally couple them — mandatory coupling would exceed
|
||
four — so the pile was unrecoverable. Both exceptions are gone; there is one rule now.
|
||
|
||
**Changed:** the siding graphic is off the industry cards. Every renderer drew one empty square per
|
||
unit of capacity, along the bottom of the card and labelled *siding* in the panels. It asserted two
|
||
things that are not true: that an industry card prints a siding, and that the siding is as long as
|
||
the box count. No industry card prints one. Industry cards now draw what is standing on them and
|
||
nothing more, exactly as a plain straight does, and the panel row is relabelled *spotted*.
|
||
|
||
A Modifier still adds its box and never adds room for a car. A Grocer's Warehouse receives into one
|
||
red box; set a Truck Dock beside it and it has two. Neither number has anything to do with how many
|
||
cars fit in front of it, which was four before the Truck Dock arrived and is four after.
|
||
|
||
**Test note:** `spots cars on industry tracks` went red on this change while measuring an
|
||
improvement. It summed cars left spotted across eight seeds, and across those eight the old engine
|
||
managed exactly ONE — a coin-flip standing in for the claim "structurally zero". With longer tracks
|
||
the bot leaves whole cuts instead of single cars, which lands on fewer seeds and delivers more cars
|
||
(22 against 19 over forty games). Widened to twenty seeds so it fails for the reason it names.
|
||
|
||
### The Truck Dock unloads, and brings nobody
|
||
|
||
**Changed:** the Truck Dock is now **+1 inbound slot, no Laborer**. It printed +1 outbound and +1
|
||
Laborer, which made it a longer-host-list copy of Forklifts — three of the seventeen Modifiers were
|
||
the same card with different names on them. A dock is where a truck backs up to take delivery, so it
|
||
adds the red box rather than the green one, and pays for the only inbound grant in the deck by
|
||
bringing no man to work it.
|
||
|
||
Two consequences, both intended and both visible before the card is played:
|
||
|
||
- **Beside Packing Sheds it now does nothing at all.** Packing Sheds is `flow: 'outbound'`, and
|
||
`usableGrant` drops a grant on a direction its host cannot use — the same rule that used to swallow
|
||
the Ice House's outbound slot at a Grocer's, running the other way. The hand tooltip reads
|
||
*"+1 in · goes beside Freight House or Packing Sheds or Grocer's Warehouse · Packing Sheds only
|
||
ships, so the inbound slot does nothing there"*, which is the whole decision stated while the card
|
||
is still in hand.
|
||
- **It is the first Modifier that grants no worker.** A facility's Laborer count no longer rises with
|
||
every card set beside it, so an unloading industry can now be capacity-rich and man-poor — which is
|
||
a queue at the industry track, not a bug.
|
||
|
||
Every tooltip, the panel's grant lines and the card reference are computed from `MODIFIER_PROFILES`,
|
||
so the one data change carries all of them; nothing prints the old numbers.
|
||
|
||
**Measured**, 200 paired seeds, developer bot: **-0.03 revenue**, 0 seeds better, 4 worse, 196
|
||
identical, with a Truck Dock standing at the end of 16 games either way. Neutral for the bot, which
|
||
places one in 8% of games and does not plan around unloading capacity. A human building toward a
|
||
Power Plant or a Grocer's is the player who feels this, and the harness cannot see that.
|
||
|
||
Both published replays that depended on the old grant went dead and were re-recorded
|
||
(`node src/sim/save-replay.ts 400 --top 3`). They went dead twice more before this release shipped —
|
||
the cut-ordering work above changed what is legal — so the three the site publishes are
|
||
`seed-2717217`, `seed-404869` and `seed-2701379`, all recorded against the final rules.
|
||
|
||
### Mainline cards say what they do
|
||
|
||
**Reported:** "mainline cards need a tooltip stating what they do. Hilly and Uncontrolled Siding — I
|
||
have no idea the impact they have on game play."
|
||
|
||
Both are invisible without one: **Hilly** charges freight double what it charges passengers, and
|
||
**Uncontrolled Siding** is one of only two cards where a following train is not stuck behind a slower
|
||
one. The tip carried the card's name and its modifiers and nothing else.
|
||
|
||
Crossing times are **computed by `crossingStages`** rather than written out, so a tooltip cannot
|
||
drift from the rule it describes — including the Hilly split, which is decided by whether the train
|
||
carries a coach:
|
||
|
||
> **Hilly** — P60 / F30 — a train carrying ANY coach crosses as a 60 (1 Stage for a fast train), and
|
||
> a freight-only train as a 30 (2 Stages). A slow train adds one Stage either way. · One train at a
|
||
> time — anything following has to wait for it to clear.
|
||
|
||
> **Uncontrolled Siding** — 60 — 1 Stage for a fast train, 2 Stages for a slow one. · TRAINS MAY
|
||
> PASS — two trains may stand on this card at once, so a following train is not held behind a slower
|
||
> one. The Double Track and the Uncontrolled Siding are the only cards that allow it.
|
||
|
||
### An Extra starts where its number sends it
|
||
|
||
**Reported:** "Extras should start at Eastern or Western Division point based on their numbers. Even
|
||
trains run to the east (start at western DP), odd run to the west (start at eastern DP). They can
|
||
also start at a control point (any office except whistlepost) at player's choice."
|
||
|
||
Every Extra used to launch **eastbound from the West Division Point**, hardcoded, with the
|
||
simplification flagged in a comment — so half of them ran the wrong way and the Control Point option
|
||
did not exist. §2.3's "odd runs west, even runs east" now governs an Extra exactly as it governs a
|
||
timetabled train, and the New Train phase **stops for the decision** the same way it stops to have
|
||
cars placed. Measured over 60 deals: 32 Extras started at the West Division Point and 29 at the East,
|
||
where before it was 61 and 0.
|
||
|
||
A Control Point is any Office above a Whistle Post, so upgrading is what buys the option — `check`
|
||
refuses a Whistle Post, and an Extra starting at an Office takes an A/D track like any other arrival.
|
||
|
||
### A modifier's grant comes back when the Office can use it
|
||
|
||
**Reported:** "Restaurant attached to a whistle stop, then upgrade to depot — depot only shows one
|
||
green / one red box. I expected two, because Restaurant increases outbound by one."
|
||
|
||
Exactly right. `hosts: ['office']` includes a Whistle Post, which is **not** a Passenger Facility, so
|
||
`usableGrant` correctly dropped the +1 outbound when the card was played — and it was gone for good,
|
||
because the upgrade only ever applied the difference between two tiers and knew nothing about what
|
||
had been discarded. The porter landed, because porters have no direction gate, which is why the
|
||
Restaurant looked half-applied rather than suppressed.
|
||
|
||
`TrackCard.modifiers` already records which Modifiers served a facility, so what was dropped is
|
||
recoverable: when the Office becomes a Passenger Facility, each of them is granted the capacity it
|
||
always printed. Keyed on the **transition**, so a Depot → Station upgrade does not pay them twice.
|
||
|
||
### The Grocer's Warehouse ships as well as receives
|
||
|
||
**Reported:** "grocer's warehouse didn't get extra outbound slot for truck dock."
|
||
|
||
It could not — and the reason was in the card data, not the modifier code. `card-reference.md`:
|
||
|
||
| Facility | Car | Direction | Laborers | Out | In | Track |
|
||
| --- | --- | --- | ---: | ---: | ---: | ---: |
|
||
| Grocer's Warehouse | Boxcar | **Both** | 2 | 2 | 2 | 3 |
|
||
| Oil Refinery | Tank car | **Both** | 3 | 2 | 2 | 4 |
|
||
|
||
and in prose: *"'Freight House' is not a card. It is the collective term for a freight facility that
|
||
loads **and** unloads — the Grocer's Warehouse and the Oil Refinery."* The engine had the Grocer's
|
||
inbound-only and the Refinery outbound-only, so §9.3's "Passenger Facilities and Freight Houses
|
||
permit cars to move each direction" named **neither of them**, and every Modifier grant on the
|
||
missing direction was silently dropped — Truck Dock, Ice House and Forklifts at the Grocer's, and
|
||
every inbound grant at the Refinery.
|
||
|
||
`TODO.md` had recorded this in v0.4.2 as *"checked, and there is no bug"*, on the reasoning that a
|
||
Grocer's is inbound-only. **That premise was the bug**, and the note is corrected.
|
||
|
||
Base capacities stay at the engine's own scale — 1 per direction a facility allows — rather than the
|
||
card reference's 2/2. Every industry here is scaled down the same way, Mine Tipple included, so
|
||
raising one alone would be a balance change rather than a correction. Two things left for Jesse in
|
||
`TODO.md`: those numbers, and the fact that the engine deals a **Freight House card** (6 copies) that
|
||
the rules say is not a card at all.
|
||
|
||
### A defence goes out with the attack it answers
|
||
|
||
**Reported:** "just like the opponent directed cards are removed from the solitaire game, remove any
|
||
of the defensive cards whose only purpose is to answer them (Facing Point Locks and Water Column and
|
||
???). No need to have them in the deck when they can never be used."
|
||
|
||
The third is the **Overpass**, and there is a fourth: Facing Point Locks exists twice, once as an
|
||
Enhancement and once as a Mainline modifier. Seven cards in all, and every one of them answers a card
|
||
that is already held out of the deck:
|
||
|
||
| Card | Copies | Answers | Which is a… |
|
||
| --- | ---: | --- | --- |
|
||
| Facing Point Locks (Enhancement) | 2 | Derail | Action card |
|
||
| Facing Point Locks (Mainline modifier) | 2 | Derail | Action card |
|
||
| Water column | 2 | Watertower | Space-use card |
|
||
| Overpass | 1 | Railroad crossing | Action card |
|
||
|
||
The engine already knew two of them were dead — `ENHANCEMENT_RULES` marks Facing Point Locks and the
|
||
Water Column `dormantSolo`, and the Overpass is the one card with no code path at all — and dealt
|
||
them anyway. The pairing now lives **on the card**, as `SimpleCard.answers`, so it is visible where
|
||
the card is defined and they come back automatically the moment `opponentCardsInDeck` does.
|
||
|
||
The dealt deck drops 213 → **206**. The catalogue is unchanged, and so are the rules: the tests that
|
||
exercise Facing Point Locks and the Water Column now mint the card directly rather than fishing it
|
||
out of a deck that deliberately no longer contains it — a rule nobody exercises is a rule that rots,
|
||
and these fire the moment their attacker returns.
|
||
|
||
*Also:* a pending Extra's placement now gets its own heading naming the Extra and its card. It had
|
||
landed in the "Making up the train" group, which — with no tray being filled — had no train to name.
|
||
|
||
### Measured drift
|
||
|
||
- **Deals producing a completed unload: 12 in 40 → 6 in 40.** The Grocer's can ship now, so the bot
|
||
often loads there instead of unloading. Unloads themselves are unharmed — 30 completed across the
|
||
40 deals measured after the change — and the test that needed one widened its sample rather than
|
||
lowering its bar.
|
||
- **Moves per productive act: ~8 → 9.9**, with the crew doing *more* work (1.48 → 1.84 acts a game),
|
||
not less. Westbound Extras exist for the first time and the Grocer's gives more switching worth
|
||
doing; a crew shuttling for its own sake would show this ratio climbing while the work stood still.
|
||
- Both published replays were re-recorded: the rules genuinely moved.
|
||
- **Three reachability canaries were under-powered and are now sampled properly**, not relaxed. The
|
||
anomaly check ran 60 games while its rarest subject, Red Flags, fires in about 4 games in 200 — so
|
||
it reported "unreachable" on the luck of the draw, which is the opposite of what a canary is for;
|
||
it runs 200 now. The sound test pooled three deals while coupling happens in 39 games in 200, and
|
||
on this seed stride the first game that couples anything is index 13 — twelve seeds still contained
|
||
none, so it pools 24. The action-list width bound went 13 → 14, re-measured across five seeds in
|
||
all three opening deals.
|
||
### The Freight Agent may stage a load before the car is there
|
||
|
||
**Reported:** "A freight agent should be able to load an outbound green box prior to having the car
|
||
there that matches the load that he's putting in there. But in order for the laborers to move it
|
||
from the green box into the men at work track, they would need to have an empty car of the
|
||
appropriate type waiting there."
|
||
|
||
Correct on both halves, and the engine had the requirement one step too early. §6.3 lists what the
|
||
Freight Agent does — *"select one Rolling Stock from the Division Yard pile and place it onto a
|
||
Facility's green Outbound box"* — and asks for nothing on the industry track. The empty car belongs
|
||
to §9.3's **Load the car**: *"a load in the Green Loading Box and an empty car of the required type
|
||
on the industry's track"*. That is the Laborer action, the one that walks the load Green → MEN → AT →
|
||
WORK.
|
||
|
||
`freightAgent.stockOutbound` was rejecting with `NO_EMPTY_CAR_SPOTTED` when no matching empty was
|
||
spotted, which made the ordinary sequence illegal: you could not have the cargo waiting on the dock
|
||
while the car to ship it in was still being switched in. Now the gate lives only on
|
||
`laborer.startLoad`, where `startableLoad` already enforced it — including the type match and the
|
||
count against loads already staged or on the sign, so two loads can never walk toward one car.
|
||
|
||
**Nothing can jam as a result.** A load in a green box is *waiting*, not stuck; only a load on
|
||
MEN | AT | WORK locks the industry track (§9.3). The staged load simply sits there until a crew sets
|
||
an empty car out.
|
||
|
||
Also updated so nothing still says the old order:
|
||
|
||
- **Impediments** told players to "bring one in with a crew FIRST, then the Freight Agent can stage a
|
||
load onto it". They are not bound to that order — it now reports the empty siding as the second
|
||
errand rather than a reason to hold the Freight Agent back.
|
||
- **The bot** ranks a stock whose load a Laborer can start next Stage above one that must wait; it
|
||
falls back to staging ahead rather than wasting the option. Previously "first legal stock" was
|
||
enough because only ready facilities were offered. 40 games: revenue and trains unchanged, cards
|
||
played 16.8 → 16.9 — the bot barely exercises the new freedom, which is a human's to use.
|
||
|
||
Four tests: stocking over a bare industry track, stocking still refused with no matching loaded car
|
||
in the Division Yard, a staged load held in the box until an empty is spotted and started once it is,
|
||
and a spotted car of the wrong type not counting.
|
||
|
||
## 0.4.6 — 2026-08-16
|
||
|
||
Four play reports in one release: trains that seemed to move before you could work them, a train card
|
||
you could not look at again, a switching list that never said which train it meant, and a Freight
|
||
Agent button that looked like it might be where the money was.
|
||
|
||
### Why did that train move? The history now says
|
||
|
||
**Reported:** "some trains seem to be moving before I can switch or do other operations on them. It
|
||
may be that the rules as written and implemented are just wrong. It may be that it's my perception."
|
||
|
||
It was perception — but the log was feeding it, in one place with an outright falsehood.
|
||
|
||
**The arrival line was wrong about Expedite.** It said an expedited train *"leaves again this same
|
||
Mainline Phase; there is no turn in which to work it"*. The expedited departure was moved to
|
||
Supervisor Shift precisely so the Porters and Laborers get their Stage with the train, so **Cargo is
|
||
available and only Local Operations is not** — the log was talking players out of the one turn they
|
||
had. Both branches now name the phases:
|
||
|
||
> Train 8 ARRIVED at the Whistle Post carrying loaded boxcar, empty coach — it stands here for the
|
||
> rest of this Stage. You can work it in Cargo now, switch it in the NEXT Stage's Local Operations,
|
||
> and it departs in that Stage's Mainline Phase.
|
||
|
||
**Every departure now carries the rule that released it.** They all read alike before, so the one
|
||
that matters — an Expedited train going at the end of the Stage it arrived — looked exactly like an
|
||
ordinary train going a Stage later:
|
||
|
||
> Train 8 HIGHBALLED — departed the Western Division Point onto the Mainline. **Why now:** it was
|
||
> made up and the Subdivision ahead was clear, so its run begins
|
||
|
||
**EXPEDITE TURNS OUT TO BE CONDITIONAL**, which is most of why it feels arbitrary at the table.
|
||
`shiftChange` has always said so in a comment — *"an expedited train that would need a ruling simply
|
||
stays, and runs normally next Stage"* — and it happens often: on one seed with ordinary traffic
|
||
running, Train 6 The Sparrow was held this way and collected **four** Local Operations turns instead
|
||
of none. That was silent. It now says so, and the card and the arrival line no longer promise that an
|
||
Expedited train can never be switched.
|
||
|
||
Three tests pin the claims the log makes, so a phase-order change cannot leave the narration lying:
|
||
an ordinary train gets a Local Operations turn and leaves in a Mainline Phase; an Expedited train
|
||
alone on the Division gets Cargo, no Local Operations, and leaves in Supervisor Shift; an Expedited
|
||
train held for a ruling gets its Local Operations turns after all.
|
||
|
||
*Also fixed:* a Division Point departure was narrated as a **fake phase marker** — "▸ train 8
|
||
highballed phase" — and the new departure line reported the wrong end of the railroad, because it
|
||
read the train's position after `enterMainline` had already moved it onto the Mainline.
|
||
|
||
### The train's card, after the card is gone
|
||
|
||
**Reported:** "once a train card's been played, how would I see that particular train card again —
|
||
what it's allowed to do and not allowed to do, and how it has to be loaded? Could I see it on a
|
||
timetable tooltip? If a train is on the board, could its tooltip include its special rules?"
|
||
|
||
Yes to both. `trainRules` already produced the card as one line and the Office card already showed
|
||
it; it now also rides on:
|
||
|
||
- **the Timetable** — hovering a slot gives the train's card under `ITS CARD —`, which is where a
|
||
player already looks for that train;
|
||
- **the Division map chip** — a train out on the Mainline can now be asked what it is, which is
|
||
exactly where "why did that leave without me?" gets asked.
|
||
|
||
Two lines in that card were wrong and are corrected. **The coach rule** said *"a cut carrying it may
|
||
only be set out at the Office"*, which reads as a place you can do it — you cannot, §A.4 refuses the
|
||
Office square outright, so the coach can never be set out anywhere. **The Expedite rule** said only
|
||
"it departs in the same Stage it arrives", which is true and useless; it now names the phases, and
|
||
the clearance exception above.
|
||
|
||
### Which train are you switching?
|
||
|
||
**Reported from play:** "when switching, make it clear which train you are switching — it is possible
|
||
to have more than one train available."
|
||
|
||
A train standing on an A/D track while a local shunts is ordinary, and the page got it wrong twice
|
||
over. `Frame.moves` was built from the **first tray in the map**, with a comment admitting it — *"One
|
||
crew. Solitaire has one, and with more the answer would depend on which is selected — a question the
|
||
page does not yet ask."* So the board highlighted one crew's reachable squares while the action list
|
||
offered every crew's moves under a single "Switching" heading of bare coordinates.
|
||
|
||
Worse, and not noticed until this was pulled apart: identical labels were collapsed across the whole
|
||
action kind. *"move to (0, 2)"* describes one crew's move exactly as it describes another's, so **one
|
||
of the two was silently dropped and could not be chosen at all** — a legal move with no button.
|
||
|
||
Now:
|
||
|
||
- **One heading per crew**, naming the train and where it stands — *"Switching Train 8, standing at
|
||
(-1, -3)"*. The train is named in the heading rather than on every button, so the buttons stay
|
||
short.
|
||
- **A "Which train are you switching?" row** when there is more than one, and the crew chosen there
|
||
is the crew whose squares the board draws. One crew at a time on purpose: every crew's highlights
|
||
at once merge into a blob and stop meaning "here is where *this* train can go".
|
||
- **De-duplication is per crew**, so two trains that can both reach the same square each get a button.
|
||
- **A train that may not switch is not offered as one.** `movesFor` is pure track geometry and six
|
||
cards print "no switching", which `check` enforces and it does not — the Circus Train was being
|
||
offered as a crew to switch, with every one of its moves refused.
|
||
|
||
Display state, deliberately not in the game: which train a player is looking at is not a fact about
|
||
the railroad, and two players may reasonably be looking at different ones.
|
||
|
||
*Found while fixing it:* the grouping key had briefly been `type + separator + trayId`, and a stray
|
||
byte in that separator collapsed every crew back into one group. The crew now travels as **data on
|
||
the entry** rather than encoded into a map key that `GROUP_ORDER` also prefix-matches on.
|
||
|
||
### The Freight Agent's red box says what it does, and what it does not pay
|
||
|
||
**Reported:** "it wasn't obvious if that was a mechanical thing or if that's the actual revenue
|
||
generation. I believe that's actually where you get the revenue, and that completes unloading the
|
||
car."
|
||
|
||
It is the mechanical one, and the button now says so. The Revenue for an inbound load is paid **one
|
||
step earlier** — §9.3: *"The last [Laborer] places the load on a red Unloading box. **Earn a Revenue
|
||
point.**"* Clearing the box is §6.3's Freight Agent operation, *"select one Rolling Stock from a
|
||
Facility's red Inbound box and place it into the Classification Yard pile"*, and the printed rule
|
||
attaches no Revenue to it. Confirmed against the engine as well as the rules: `clearInbound` emits
|
||
`inboundCleared` alone, with no `revenueChanged` beside it, and the score does not move.
|
||
|
||
clear red box at (0,0)
|
||
|
||
becomes
|
||
|
||
send the loaded coach in the red Inbound box at (0,0) to the Classification Yard — pays nothing
|
||
(the Revenue was paid when the passengers detrained); it frees the last slot so more passengers
|
||
can detrain here
|
||
|
||
The wording follows the facility: an inbound freight load and a coach whose passengers have detrained
|
||
both wait in the same red box, and both were paid for a step earlier.
|
||
|
||
### Making up a Local says which order the cars go on in
|
||
|
||
**Reported from play:** "I can't drop a car at all — trying to get an empty to an industry, but I
|
||
can't drop any cars on the siding first." Chased through two wrong guesses (it was not movement, and
|
||
it was not the 0.4.5 fix falling short) to a single train and a single arrangement.
|
||
|
||
Trains **7/8 Local** print *"coach must remain on station track if switching"*, which the engine
|
||
reads as "the coach is never set out". A cut always comes off an **outer end**, so if the coach is on
|
||
one outer end and the engine is on the other, every cut on offer contains the coach — and the train
|
||
is locked. It cannot set out its freight car, and it cannot even uncouple to **run around**, because
|
||
that means leaving the coach standing too. The one escape is a Small Yard, of which there is exactly
|
||
1 copy in the 213-card deck.
|
||
|
||
All six arrangements, checked against the rules rather than reasoned about:
|
||
|
||
```
|
||
ENGINE boxcar coach NOTHING — the train is locked
|
||
boxcar ENGINE coach can set out: boxcar
|
||
boxcar coach ENGINE can set out: boxcar
|
||
ENGINE coach boxcar can set out: boxcar
|
||
coach ENGINE boxcar can set out: boxcar
|
||
coach boxcar ENGINE NOTHING — the train is locked
|
||
```
|
||
|
||
Two of six lock, and they are exactly the two where the coach holds one outer end and the engine
|
||
holds the other. **It is only the Local.** Over 60 games, every other train that stood in a district
|
||
with cars where a set-out was allowed managed one — Drag Freight, Heavy Freight, the Freight Extra,
|
||
the Director's private car, Yard Xfer, Appleseed — 127 positions, zero blocked. The Local: 1,181
|
||
positions, every one refused, all `COACH_MUST_STAY`.
|
||
|
||
The cruelty is that the locking order `ENGINE boxcar coach` is both the prototypical mixed-train
|
||
make-up and what the game naturally produces: cars are appended as they are clicked with the engine
|
||
on the nose, so the **last car added takes the outer end**, and the freight car is the natural first
|
||
pick.
|
||
|
||
**So the make-up panel now says so.** The whole remedy is "do not add the coach last", and it is
|
||
stated at the only moment it can still be acted on:
|
||
|
||
- nothing on the train yet → *"Add the coach FIRST… ENGINE, coach, freight is the order that works."*
|
||
- coach on, freight still to come → *"Now add the freight car — it takes the outer end, leaving the
|
||
coach safely inside."* A **hint**, not a warning: the coach lands on the outer end the moment it
|
||
goes on, including for the player who has just been told to put it there, and colouring that as a
|
||
mistake punishes them for taking the advice.
|
||
- coach on the outer end with nothing left to add → a real **warning**, because there is no next step.
|
||
- freight already on and the coach still to come → *"send it out without the coach if you want it to
|
||
work the district"*, since "add the coach first" is advice it is too late to take.
|
||
|
||
Shown **only** for a train the order can lock, so it is not a standing caption a player learns to
|
||
skip — every other train, and a Local with no coach coming, get nothing.
|
||
|
||
**The rules are untouched.** This is guidance, not a rules change; the open §A.4 question about where
|
||
the Local's coach stands while its engine works is still open in `TODO.md`, and answering it (letting
|
||
the coach be set out at the Office, as the card's wording suggests) would let the prototypical
|
||
make-up work and make this advice unnecessary.
|
||
|
||
**Verified end to end**, not just unit-tested: played to a real Local make-up, followed the advice
|
||
through both steps, and confirmed the resulting `ENGINE coach boxcar` can set a car out.
|
||
|
||
## 0.4.5 — 2026-08-14
|
||
|
||
### A train can back out of a curve again
|
||
|
||
**Reported one commit after 0.4.4 shipped:** "seems like I can't drop a car at all. I'm trying to
|
||
switch to get an empty car to an industry, but I can't drop any cars on the siding first."
|
||
|
||
Setting out a cut needs no Move and is refused almost nowhere — but you may only set out where the
|
||
train **is**, and the Office square is barred outright (§A.4). So "I cannot drop" is nearly always
|
||
"I cannot get there", and getting there means leaving the Running Track through a turnout and a
|
||
curve. 0.4.4 fixed the forward half of exactly that and left the reverse half wrong:
|
||
|
||
- **Forward** exits by `facing` — fixed in 0.4.4 to read the card's far end rather than assuming
|
||
`opposite(entry)`.
|
||
- **Reverse** still exited by `opposite(facing)`, which is the other end of a **straight** and of
|
||
nothing else. A crew facing north on a north-west curve backs out through **west**; `opposite('n')`
|
||
is a south port the card does not have, and `exploreMoves` returns nothing at all from a port the
|
||
card lacks.
|
||
|
||
So 0.4.4 moved the problem rather than solving it. Before it, a crew that rounded a curve could only
|
||
back out; after it, a crew could only carry on. Either way a siding entered one way could not be left
|
||
the other, and a siding that had to be *backed* into could not be entered at all.
|
||
|
||
`reversePort` now asks the card for its other end, the same way `farPort` does going forward. A train
|
||
always stands on a two-port card — §A.1 forbids finishing a Move on a turnout — so there is exactly
|
||
one other end to find.
|
||
|
||
**How much of the board this was hiding.** Over 25 bot games, at 1,937 points where a crew could have
|
||
been switching, comparing what the board highlights now against what it highlighted before:
|
||
|
||
| | |
|
||
| --- | --- |
|
||
| squares offered, before → now | 7,461 → 10,040 (**+35%**) |
|
||
| positions hiding at least one legal square | 694 (**35.8%**) |
|
||
| positions with **no legal move at all** | 300 (**15.5%**) |
|
||
|
||
Both figures understate it: the "before" reckoning was run with occupancy ignored, so it was allowed
|
||
squares that another train was actually sitting on. A crew counted as stuck was stuck even on the
|
||
generous reading.
|
||
|
||
**Tests.** The failing case is pinned three ways — backing off a curve, carrying on round one, and
|
||
the whole errand from the report: take a cut off the Running Track into a siding, set it out, and
|
||
come back for the industry. That last one fails on 0.4.4 with *"the crew is stranded on the siding —
|
||
it cannot return to the Running Track"*, which is the report in one line.
|
||
|
||
`sim.test.ts` needed a wider sample rather than a lower bar: crews now have real switching to do, so
|
||
the bot lays fewer track pieces per game and five seeds no longer produced the 40 placements the
|
||
dead-end **rate** is measured over. Eight seeds now, same stride; the rate itself came out at 0.147
|
||
against a bar of 0.25.
|
||
|
||
## 0.4.4 — 2026-08-14
|
||
|
||
Out of a play session: engines that pointed north, a card whose name collided with five other things,
|
||
a New Game dialog that only asked for a seed — and then, chasing a mirrored consist, two movement
|
||
bugs that had been there all along.
|
||
|
||
Both playtest reports were confirmed against Jesse's own saved game,
|
||
`docs/station-master-seed493290760-day2.json` (seed 493290760, two Days, 127 intents), which is kept
|
||
as the evidence for what follows.
|
||
|
||
### A curve is not a straight, and a train that rounds one knows it
|
||
|
||
**Reported:** "a train reversed into a siding and the display of the cars was reversed."
|
||
|
||
`facing` is the port the engine would leave by, and a Move recorded it as `opposite(entry)`. That is
|
||
the far end of a **straight** and of nothing else: a curve is an arc between two ADJACENT edges, so a
|
||
train entering a north-west curve through its west port comes out facing **north**, not east. Every
|
||
train that rounded a curve was left facing a port its own card does not have.
|
||
|
||
Two things went wrong with that, and only the second was visible:
|
||
|
||
- **Movement.** `movesFor` explores from `facing`, and a port the card lacks yields no destinations
|
||
at all — so a crew that rounded a curve could only ever back out the way it came. It could not
|
||
continue round the corner it had just taken.
|
||
- **Display.** The east-west sense the board draws is carried from `facing`, so a curve that had
|
||
really turned the engine west could leave the board still drawing it east — the consist mirrored,
|
||
which is what was seen.
|
||
|
||
The forward case now asks the **card** for its far end (`farPort`). The reverse case is unchanged and
|
||
was already right: backing up, the engine trails and points out through the port the train came in
|
||
by, whatever the track does underneath — there is a test pinning that so the fix cannot drift into it.
|
||
|
||
**The reported moment, out of the save.** Three times in two Days the crew backs off the Running
|
||
Track into the curved siding at (1,-2) — a `sw` arc, so entering it from the south leaves the engine
|
||
facing south. Intent #109, drawn both ways:
|
||
|
||
```
|
||
came from (0,0) office [ew] west cab tnk [>] east
|
||
BEFORE the fix west [v] box tnk cab east
|
||
AFTER the fix west cab tnk box [>] east
|
||
```
|
||
|
||
The engine was on the east end before the move and is on the east end after it — backing up does not
|
||
turn a train around. The old renderer flipped the whole strip to nose-left the moment `facing` stopped
|
||
being `'e'`, which is precisely the mirroring that was reported.
|
||
|
||
### A train leaving a district drops its spur port
|
||
|
||
The same family, found while fixing the above. Nothing reset `facing` when a train left an Office, so
|
||
a crew that had been shunted onto a north-south spur carried a compass port out onto a Division that
|
||
runs east and west — and then into the next Office, whose card has no north or south edge at all.
|
||
With neither `facing` nor its opposite on the card, `movesFor` returned nothing in either direction:
|
||
**the train arrived at the next Office unable to make a single Move.** It also drew a ▲ on the
|
||
Division map, where there is no north to point at.
|
||
|
||
A train out there is running one way along an east-west railroad with its engine at one end, so
|
||
`enterMainline` now says so. This is the same stale port that made the ▲ on the Division map — the
|
||
`railFacing` change above stopped it being *drawn*, and this stops it existing.
|
||
|
||
### Departures after Cargo: investigated, not a bug — but one card contradicts itself
|
||
|
||
Also reported: "a train left at the end of the Cargo phase — shouldn't it wait for the next Mainline
|
||
phase?" It should not, and it did not: **only Expedite trains do this**, which is Q3 working as
|
||
agreed. In the save it is **6 The Sparrow**, twice — arriving in Stage 9's Mainline and highballing
|
||
in Stage 9's Supervisor Shift, then the same in Stage 10 of Day 2. The one non-Expedite train in the
|
||
game, 12 Drag Freight, arrived in Stage 8 and left three Stages later in a **Mainline** phase, exactly
|
||
as it should. Measured more widely, over 40 bot games, the split is perfect: every departure with no
|
||
Local Operations turn was an Expedite train, and every non-Expedite train got at least one.
|
||
|
||
Two things are worth writing down because they read as bugs and are not:
|
||
|
||
- **Expedite departs in Supervisor Shift, not in Cargo.** Q3 as recorded says the train "gets a
|
||
second `moveTrain` in the same Mainline Phase"; the code deliberately does not, because that had
|
||
expedited trains gone before a single Porter could reach them. Supervisor Shift is the last phase
|
||
of the Stage, so it is still the Stage the train arrived in — it just looks like "I finished Cargo
|
||
and it left", because Supervisor Shift needs no input and runs itself.
|
||
- **Expedite costs the train its Local Operations turn**, since Local Ops is phase 1 and the train
|
||
arrives in phase 3 and leaves in phase 5. Harmless for four of the five — Crack Limited, The
|
||
Sparrow, the Military train and the Light Engine all print "no switching" and would decline it.
|
||
|
||
**Except for 3/4 Express**, which prints *"may drop or pick up one freight car at every location"*
|
||
and is also Expedite. That budget is spent by coupling and setting out, which happen only in Local
|
||
Operations — so the Express has a printed ability it can never use: 31 Office visits in 40 games, 31
|
||
with no turn to use it in. X14 Fruit Growers Express is in the same position, though its extra-reefer
|
||
line is a note today with no mechanics behind it. Left alone pending Jesse's call and recorded in
|
||
`TODO.md`; it is a rules contradiction on the card, not a fault in the timing.
|
||
|
||
### Replays are replaced rather than piled up
|
||
|
||
Both fixes change which Moves are legal, so the published replays stopped replaying — correctly, and
|
||
the liveness test caught it. Re-recording used to write the new set **beside** the old one, and the
|
||
old set is precisely the one whose rules have just moved, so dead files accumulated and the test
|
||
failed on them forever. `save-replay.ts` now retires what it replaces (only once a replacement has
|
||
verified), and writes the `rules` block into each file so a replay can never again be silently
|
||
re-dealt. `harness.test.ts` was passing `{ seed, history }` and dropping `rules` on the floor, which
|
||
would have replayed every published file under the pre-dialog defaults whatever it said.
|
||
|
||
### The engine points east or west, always
|
||
|
||
**Reported:** a crew that turned onto a north-south spur was drawn with a ▲ over it, and the change
|
||
of convention was harder to read than no arrow at all.
|
||
|
||
`facing` on a Crew Tray is a **port** — 'n', 's', 'e' or 'w' — because movement needs one: a crew
|
||
standing on a north-south spur has to be able to leave by 'n' or 's', and the last attempt to derive
|
||
east/west from the direction of the run stranded 29 of 62 leftover crews on north-south track with no
|
||
legal move. So the port stays exactly as it is, and what changed is the **drawing**.
|
||
|
||
A new `railFacing` on the tray carries the east-west sense across north-south track: it updates
|
||
whenever `facing` becomes 'e' or 'w' and holds its value in between. That is the railroad's own
|
||
convention, where compass north on a branch is still timetable east. It follows the engine around
|
||
180° of curves, because a train that runs forward through two curves really has turned around — and
|
||
it does *not* move when a train backs up, because a train that backs up has not.
|
||
|
||
**It also fixes a bug nobody had reported yet.** Nothing resets `facing` when a train leaves a
|
||
district, so a crew that shunted onto a north-south spur and then departed carried its 'n' out onto
|
||
the Division map — where there is no north or south — and drew ▲ there too.
|
||
|
||
The Frame's `facing` is now typed `'e' | 'w'`, so this is enforced rather than merely observed, and
|
||
the Office card lays a north-south crew's consist east-west like every other train instead of pinning
|
||
it nose-left.
|
||
|
||
### The Yard mainline card is now the Interchange
|
||
|
||
Same 60, same "sort cars into any new order", same entry points, same art. What it did not have was a
|
||
name of its own: **Division Yard, Classification Yard, Salvage Yard, Yard Office and Small Yard** are
|
||
five other things in this game, and none of them is this card. The internal key is renamed with it
|
||
(`MainlineKind` `'yard'` → `'interchange'`) so the two cannot drift. The other five are deliberately
|
||
untouched — they merely shared a word.
|
||
|
||
### New game asks for the rules, not just the seed
|
||
|
||
It was a `prompt()` asking for a seed. Two of the three things that decide what kind of game you are
|
||
about to play had no way in at all: the opening hand had been changed twice with no way back to the
|
||
earlier rule, and the three revenue rates were constants in the source. Balance is the open question
|
||
this game has, and settling it means dealing several games at different settings — which needs a
|
||
dialog, not a rebuild.
|
||
|
||
**Starting hand**, three options, all of which have been the rule at some point:
|
||
|
||
- **Three random cards** *(new default)* — the prototype rule. At the hand limit already.
|
||
- **Six random cards** — twice the choice, still no guaranteed track.
|
||
- **Three random track and three random non-track** — the v0.4.x deal, from two shuffled piles.
|
||
|
||
**Revenue**, three rates, each 0–5:
|
||
|
||
- **Passenger revenue per coach**, default **1**. Paid when a coach is boarded and again when it is
|
||
detrained — both halves of the movement, as it has always worked.
|
||
- **Freight revenue per load**, default **1**. Paid when a load is made up outbound and again when it
|
||
is broken inbound.
|
||
- **Train revenue per transit**, default **0** — *changed from 1*. Paid to every player when a train
|
||
runs off the end of the Division. At 1 it was worth ~5.4 Revenue against a bot mean of 7.0: the
|
||
railroad was earning most of its money from the one thing nobody has to work for, and the freight
|
||
and passenger economies the game is about could not be read through it.
|
||
|
||
Zero is a real setting rather than "one, suppressed" — no revenue event is emitted at all, so the
|
||
history does not fill with "+0 Revenue" for work that did not pay.
|
||
|
||
**A seed no longer names a game**, so the settings ride in the URL beside it
|
||
(`?seed=430&hand=sixRandom&passenger=1&freight=1&transit=0`) and the header carries a readout of what
|
||
is in force. A playtest note reading "scored 4" is worthless without it.
|
||
|
||
**Saves carry the rules they were dealt under.** A save is a seed and a list of intents: replay it
|
||
under different rules and it is a different game, and the symptom is not an error but a replay that
|
||
quietly stops early — which `TODO.md` records happening twice unnoticed, one of them 42 intents into
|
||
360. `Save.rules` is written from now on, and a save without it replays under the pre-dialog rules
|
||
(3+3, and 1 per transit) rather than today's defaults, so the three published replays still run.
|
||
|
||
**Under the hood.** The settings live on `GameConfig.houseRules` as a partial, resolved in one place
|
||
by `houseRules()`, which clamps and rounds — so a hand-edited URL cannot deal a game at 900 Revenue a
|
||
coach. They reach the page on the `Frame` rather than off the config, because a remote client holds
|
||
no `GameState` and still has to be able to answer "what does a load pay here?"; `createLocalSession`
|
||
takes house rules rather than a whole `GameConfig` for the same reason.
|
||
|
||
**Exercised, not just compiled.** Ten developer-bot games per setting, solitaire Standard — far too
|
||
small a sample to conclude anything about balance, and enough to show the dials are wired to the
|
||
game rather than to the dialog:
|
||
|
||
| Setting | Dealt | Mean Revenue |
|
||
| --- | --- | --- |
|
||
| `threeRandom` (default) | 3 | 4.00 |
|
||
| `sixRandom` | 6 | 2.00 |
|
||
| `threeTrackThreeOther` | 6 | 0.50 |
|
||
| pax 1 · frt 1 · **trn 0** (default) | — | 4.00 |
|
||
| pax 1 · frt 1 · **trn 1** (the old rule) | — | 9.30 |
|
||
| pax 0 · frt 0 · trn 0 | — | −0.20 |
|
||
| pax 5 · frt 5 · trn 0 | — | 20.80 |
|
||
|
||
The transit rule is worth **+5.30** here against the +5.4 measured over 200 games before it became a
|
||
setting, which is the number this change was made to get out from underneath. All zeroes lands just
|
||
below zero — collision penalties, with nothing left paying — which is the right shape for an economy
|
||
switched off. **Read no balance conclusion from the two random-deal rows**: ten seeds is noise, and
|
||
the hand rows are three different games rather than the same game dealt differently.
|
||
|
||
**Tests.** 513 passing, six of them driving the dialog through the *emitted bundle* — the failure
|
||
mode here is wiring, not logic. Four existing tests had to be re-pinned rather than merely updated:
|
||
the opening deal changes the RNG stream, so `advance.test.ts` was relying on whichever mainline card
|
||
seed 1 happened to lay down and now pins its own terrain, and the widest action list was re-measured
|
||
over five seeds in all three deals (13 under the random deals, 10 under 3+3).
|
||
|
||
## 0.4.3 — 2026-08-14
|
||
|
||
### A load has to have somewhere to go
|
||
|
||
Jesse walked through how loading an industry is meant to work at the table, and the engine got four of
|
||
the five steps exactly right. It got the first one wrong, in both directions.
|
||
|
||
**You could stage cargo against no car.** §9.3 requires an empty car of the right type standing on the
|
||
industry's track before anything else happens — *"otherwise you are just dropping cargo onto the
|
||
tracks, pointless waste"*. The engine checked for that car only at the **last** step, when the load
|
||
came off WORK. So you could fetch cargo with the Freight Agent, walk it M→A→W across three Stages and
|
||
three Laborers, and only then find there was nowhere to put it. Worse than waste: from the moment the
|
||
load lands on M the industry track is **locked**, so no train can come in to spot the car you now need.
|
||
The only way out was a Freight Agent unjam — another whole turn, to undo a move the rules should never
|
||
have offered.
|
||
|
||
Now gated in three places, all counted rather than merely present:
|
||
|
||
- **`freightAgent.stockOutbound`** — no cargo is fetched unless a matching empty is spotted and not
|
||
already promised to a load already in the box or on the sign.
|
||
- **`laborer.startLoad`** — the same check again, because it is not redundant: coupling is mandatory,
|
||
so a crew running over that industry track *must* pick up the spotted empty, and the cargo you
|
||
staged an hour ago can be left with nothing.
|
||
- **`laborer.beginUnload`** — the mirror. The red Inbound box is where an inbound load lands, and it
|
||
was checked only at the final step too. Counted, because only the W box must be free to begin: once
|
||
a load moves W→A a second can start behind it, and every red box in play holds exactly one car.
|
||
|
||
**Strict type matching throughout** — a hopper load cannot be swapped onto a tank. And `startLoad` no
|
||
longer always takes `outboundBox[0]`: it starts the first load that has a car to land on, so a Power
|
||
Plant holding a hopper load and a tank load against one spotted tank works the tank instead of
|
||
reporting the whole facility blocked.
|
||
|
||
**Passengers are deliberately exempt and it is written down.** People can wait on a platform for a
|
||
train that has not arrived, so a Passenger Facility still stocks freely. Freight cannot: a crate on the
|
||
ground is not a shipment.
|
||
|
||
**Measured before building it, which is why it was worth building.** Requiring the car removes **78% of
|
||
the `stockOutbound` moves the menu offered** (704 → 157 across 200 games) — but **99.3% of the moves the
|
||
bot actually took survive** (138 of 139). The rule deletes offers a competent player would never have
|
||
used. Bot unchanged at 7.3 mean. On the unload side it removes 2.2% of offers, and the jam it prevents
|
||
was caught happening three times in 200 games.
|
||
|
||
### The Blocked panel says what to do, and in what order
|
||
|
||
The refusal codes never reach a player, because the menu simply stops offering the action — so with
|
||
78% of stocking moves gone the panel was the only thing that could explain it. It said *"green box
|
||
empty — nothing to load (needs a Freight Agent action)"*, which is step two told to someone who has not
|
||
done step one. It now says to bring a car in first, names the commodity, and distinguishes "MEN is
|
||
occupied" from "there is nothing here to load onto", which send you to fix quite different things.
|
||
|
||
### Rolling stock is exactly conserved
|
||
|
||
`TODO.md` recorded the inbound path minting ~1.3 cars a game and blocked tuning `ROLLING_STOCK_SUPPLY`
|
||
until it was settled. Re-audited: **exactly conserved, 100 games out of 100, range 0..0.** The
|
||
asymmetry had already been closed from the other end when `unloadBegan` and `passengersDetrained`
|
||
started taking their replacement empty out of the Division Yard instead of conjuring it — a load is a
|
||
car that moved, not a car that appeared, which is exactly the tabletop procedure Jesse described.
|
||
|
||
Both conjuring fallbacks now **throw** instead of minting, so the leak cannot come back quietly;
|
||
neither fired across the suite or the audit. `ROLLING_STOCK_SUPPLY` is unblocked for the rebalance.
|
||
|
||
(The original audit's arithmetic was off in the same way mine was on the first attempt: cars set out on
|
||
a card live in `card.standing` and are easy to leave out of the count, which makes a conserved game
|
||
look like a leaking one.)
|
||
|
||
### Replays
|
||
|
||
Two of five died on the rule change — they replayed 138/336 and 81/347, which on screen looks exactly
|
||
like a game that ended early. Re-recorded; three published, all verified to their last intent.
|
||
|
||
## 0.4.2 — 2026-08-13
|
||
|
||
### A completed run pays the whole table
|
||
|
||
The departure Revenue used to pay 1 to the Office a train left — "it cleared YOUR section". On a
|
||
five-Office railroad that paid five separate times for one train, and paid most to whichever Office it
|
||
happened to pass first. It now pays **once, when the train runs off the end of the Division, and it
|
||
pays every player**: getting a train the length of the railroad is the shared achievement, and every
|
||
Office it crossed had to clear it to happen.
|
||
|
||
The log says so in one line — *"Train 5 has completed its run, leaving via the Eastern Division Point
|
||
carrying 3 coaches. All players get 1 Revenue."* — and because nobody took a turn to cause it, it is
|
||
also announced on screen and given a sound of its own: two long horn notes falling away, the second
|
||
lower and quieter. It is the only cue in the game that is not somebody's action.
|
||
|
||
Solitaire is nearly unmoved, 7.0 → 7.3 mean over 200 games, because one player's departures and
|
||
completions run at almost the same rate. **A multi-player game is a completely different shape** and
|
||
needs measuring once there is one.
|
||
|
||
### Expedited passenger trains could never be worked, and now can
|
||
|
||
**Reported from play: "passenger trains arrive at my Office and move on before I can load or unload."**
|
||
They did. Every coach-carrying express prints Expedite — 1/2 Crack Limited with three coaches, 5/6 The
|
||
Sparrow with two, 19 Military with two — and Expedite was implemented as a second move inside the same
|
||
Mainline Phase. Load/Unload runs *after* Mainline, so the train was always gone first. Measured over 60
|
||
games: **Train 2 arrived 32 times and stood for a Load/Unload phase in none of them.** Trains 4 and 6
|
||
likewise zero. The Crack Limited's own card says "stop at Terminals only", which it could never do.
|
||
|
||
An expedited train now stands through Load/Unload and departs at the **end** of the Stage instead. Q3's
|
||
"departs the Stage it arrives" is still literally true — it does not lay over — and §8.1 still applies,
|
||
so it can be held. Coach trains standing for Load/Unload went **116 → 229** across the same 60 games;
|
||
Train 2 from 0 to 35, Train 6 from 0 to 17.
|
||
|
||
The cost is real and is logged as drift: a train occupying an A/D track and the Office square for a
|
||
Stage is in the crew's way, so switching work fell ~16% (1.76 → 1.48 productive acts over 400 games)
|
||
and the worst game went −3 → −9 as Offices fill. That is the change doing what it is supposed to do.
|
||
|
||
### Three silences around passenger work, all of which said nothing at all
|
||
|
||
The refusal codes never reach a player — the menu simply does not offer an illegal action — so the
|
||
Blocked panel is the only thing that can explain a missing button, and it skipped every non-freight
|
||
facility. Its passenger section also only looked at trains carrying a *loaded* coach, so a train
|
||
arriving to **pick up** produced no line at all.
|
||
|
||
Underneath, the engine's own answers were wrong in two ways worth fixing regardless:
|
||
|
||
- **A Whistle Post reported `RESOURCE_SPENT`** — "all Porters already used this Stage" — to a player
|
||
who had used none. Its Office card carries a passenger facility so that an upgrade is a property
|
||
change rather than a card swap, and that facility has `porters: 0`. Now `NO_PORTERS_HERE`, and the
|
||
panel says to upgrade the Office. 15 occurrences in 60 games.
|
||
- **`NO_TRAIN_AT_OFFICE` was returned while a train was standing at the Office**, as the catch-all for
|
||
every other reason. 51 occurrences with a coach train in front of the player: 27 with nobody waiting
|
||
to travel, 24 with passengers waiting and every coach already full. Those are now
|
||
`NO_PASSENGERS_WAITING`, `NO_EMPTY_COACH`, `NO_LOADED_COACH`, `INBOUND_BOX_FULL` and
|
||
`NO_EMPTY_COACH_IN_YARD`, and each has its own line in the panel.
|
||
|
||
The panel also now warns, in amber, when the train in front of you is expedited: *"it leaves at the END
|
||
of this Stage, so this is the only Load/Unload phase it will stand for."*
|
||
|
||
### Replays
|
||
|
||
`seed-6257010` died on the Expedite change — it replayed 161 of 335 intents, which on screen looks
|
||
exactly like a game that ended early. Re-recorded with `save-replay.ts`; `seed-3334899` survived
|
||
untouched and was kept. Four published replays, all verified to replay to their last intent.
|
||
|
||
## 0.4.1 — 2026-08-13
|
||
|
||
### §4.4's opening D12 now decides who sits where
|
||
|
||
It had been rolled and thrown away — `void divisionRolls`, with seating fixed by array order — so the
|
||
rule decided nothing, and `seating` was the identity mapping in every game ever played. `seating[seat]
|
||
= player` now runs ascending by roll from seat 0 (west, beside the Western Division Point) to the last
|
||
seat: "highest is the Eastern Division Point".
|
||
|
||
The rule names only those two ends, because at a table the players are already sitting in a chain and
|
||
the roll only says which way round it is. There is no physical table here, so the roll orders
|
||
everybody — it uses a number every player is already told to roll, and it makes the roll matter to
|
||
more than the winner. Ties break toward the lower player index sitting further east, the same
|
||
first-max-wins convention `argmax` already uses for the Superintendent roll.
|
||
|
||
**Turning it on immediately found three more of last commit's bug class.** Acting order, the opening
|
||
deal and the Fedora all did `(player + n) % players`. Every one of them is a statement about the
|
||
physical chain — "starting from the Superintendent and proceeding left" (Gap 1, §4.7, §5) — so every
|
||
one of them is seat arithmetic, and all three were right only while seating was the identity mapping.
|
||
They now go through a new `playerLeftOf(state, player, n)`. This is the payoff of the split being
|
||
exercised by real games rather than only by tests that rotate `seating` by hand: eight of these were
|
||
found by inspection last commit, and three more fell out of simply making the rule work.
|
||
|
||
`state.openingRolls` keeps both D12s, indexed by player, so a lobby can show the chain forming rather
|
||
than only its result (`lobby-and-sessions.md` §4). `Frame.players` gained `seat` for the same reason —
|
||
the list is in player order because it is about people, and a client that wants to draw the table
|
||
west-to-east now can.
|
||
|
||
**Solitaire is untouched, and the proof is that it had better be:** one player is one seat, so the
|
||
permutation is trivially `[0]`. All four published replays finish on their recorded Revenue (27, 31,
|
||
29, 28) and the bot is unmoved at 7.0 mean over 200 games.
|
||
|
||
Nine multiplayer tests failed on the change and every one of them was the test being wrong — each had
|
||
encoded the identity mapping, which is exactly what made them pass before. `readyToLeave` was putting
|
||
a *player* index into a tray's `seat` field; the Subdivision tests upgraded "player 1's Office" when a
|
||
Subdivision is a stretch of the physical chain; the Fedora test recorded player indices where §5 is
|
||
about seats. Two new tests replace the one that asserted the identity mapping outright: seating is a
|
||
permutation (and is still `[0]` in solitaire), and over 40 seeds the highest roller is easternmost with
|
||
the chain ordered throughout.
|
||
|
||
|
||
### The intents are canonical; the event log narrates
|
||
|
||
The README, four architecture documents and six source comments all claimed `state = fold(events)`.
|
||
It was never true, and it was load-bearing — the stated justification for reconnection, restart
|
||
recovery and persistence, none of which were built yet, so nothing had ever tested the claim.
|
||
|
||
Measured before deciding: **`advance.ts` never calls `reduce`.** Fourteen of the forty-six event types
|
||
are emitted after the phase driver has already mutated state — the clock, and the whole Mainline
|
||
phase. Folding the log rebuilds a district and not a railroad; every train movement in the game is
|
||
missing.
|
||
|
||
**Settled the cheap way, because the plan never needed fold.** Persistence is
|
||
`{ engineVersion, seed, config, history: Intent[] }` (`multiplayer.md` §10), the wire carries `Frame`s
|
||
rather than events (D2/D3), and reconnection is a fresh `Frame` rather than an event tail — so making
|
||
the phase driver reduce would have been a rewrite of the most rule-dense code in the project to buy
|
||
something nothing uses. `lobby-and-sessions.md` §6 previously said "persist the event log"; it now
|
||
persists the intents, which are smaller still and which `fromSave` already replays.
|
||
|
||
`test/events.test.ts` pins the unreduced set as a deliberate change-detector: shrink it and the test
|
||
tells you which documents now understate the engine; grow it and you have added another event on the
|
||
mutate-then-describe path. It also asserts the property that *does* hold — that replaying the intents
|
||
reproduces the board, the score, the clock and the crews exactly — and that `Save` still carries
|
||
nothing but a seed and a history.
|
||
|
||
### `lobby-and-sessions.md` reviewed, and four decisions taken
|
||
|
||
The persistence rewrite above left the document internally consistent but unreviewed — it was written
|
||
before `multiplayer.md` and contradicted it in four places, and the code in two more.
|
||
|
||
*Contradictions with the newer plan:* it had **no access control at all** ("a game code is the whole
|
||
discovery mechanism") where D14 added a server-wide join secret; its timeout table used the fictional
|
||
`Switch.End`-style names and **omitted `freightAgent.end`**, so a player who chose Freight Agent never
|
||
timed out; it opened "one actor at a time", which D19 stopped being true; and "do not substitute an AI
|
||
player" contradicted D8's bots-at-lobby-time.
|
||
|
||
*Contradictions with the code:* §4 described the opening D12 for the Eastern Division Point as
|
||
happening, when `setup.ts:266` rolls it and does `void divisionRolls` — seating is array order, so the
|
||
rule decides nothing. `PlayerDisconnected` was written as an event; there is no such type, and it
|
||
should stay out of `GameEvent`, which must remain replayable from a seed.
|
||
|
||
**Four decisions:**
|
||
|
||
- **The session token names the PLAYER, not the seat.** `multiplayer.md` §10 said seat; that became
|
||
wrong in v0.4.0, and Employee Rotation is exactly the case where it bites — a token naming a chair
|
||
seats a returning player in someone else's Office. Both documents now agree, and say the seat is
|
||
`seatOf(state, player)`.
|
||
- **2 to 4 players**, enforced in the lobby because the engine enforces nothing. Set to what is
|
||
actually exercised rather than to the previous guess of 6. Noted as a lobby judgment, not a rules
|
||
limit — the rules describe 5+ and the engine implements it.
|
||
- **No forcing turn timer.** Cut, on the same objection that keeps bots out of running games: the
|
||
clearance decision changes somebody else's score, so anything answering it automatically changes
|
||
the game. Moved to `TODO.md` to explore only if halted games prove to be a real problem, keeping
|
||
the one piece of reasoning worth saving — that **deny** is the safe default, since a held train
|
||
costs a Stage and a wrecked one costs 5 Revenue and feeds the collision floor.
|
||
- **A turn clock that records rather than enforces.** Wall-clock per player per phase, so "how long
|
||
does a 4-player game take, and which phase is the wait?" becomes measured instead of guessed — the
|
||
one measurement bot simulation cannot produce, because the bot does not think. Explicitly outside
|
||
the rules engine (which has no clock and must not acquire one) and outside the canonical record
|
||
(a replay must reproduce a game from decisions alone). Phase 3, item 16.
|
||
- Plus: **host rights pass to the earliest-joined remaining player** if the host leaves before start,
|
||
so no lobby is stuck behind a closed tab.
|
||
|
||
### Documentation reconciled with the code
|
||
|
||
`docs/design.md`'s status section was four milestones stale: a 115-card deck (it is 213 in play, from
|
||
a 235-card catalogue), 134 tests (497), a 0% win rate and 0.9 Revenue/Day (7.0 mean, 1.4/Day, 5 wins
|
||
in 200), and "Next: step 7 begins the server" when Phase 2 is deliberately held. Rewritten as the
|
||
shape of the project rather than a running tally, pointing at the CHANGELOG and `TODO.md` for
|
||
anything that moves — a hand-maintained tally is exactly what drifted.
|
||
|
||
The README also said the rules were **fully specified**, which overstates it: three questions are
|
||
genuinely open — where the Local's coach stands while its engine works (a §A.4 question rather than a
|
||
train-card one), Poling, the one card in the deck with no defined behaviour, and whether a Heavy
|
||
Grade's orientation is rolled or chosen at setup. Thirteen prototype gaps were closed; these three
|
||
came after, and the bullet now says so and names them.
|
||
|
||
|
||
## 0.4.0 — 2026-08-13
|
||
|
||
### Phases 0 and 1 of the multiplayer plan — the seams, not the server
|
||
|
||
`docs/architecture/multiplayer.md` is the plan; this is its first two phases. Nothing a player can see
|
||
changed, and that is the exit criterion: the whole point is that solitaire is byte-for-byte the same
|
||
game while the code underneath it stops assuming there is only ever one of you.
|
||
|
||
**Seat and player are now different things.** They were the same integer everywhere, which is correct
|
||
today and wrong the moment Employee Rotation moves someone to a different chair. Offices and districts
|
||
are keyed by SEAT; hands, Revenue and the Fedora belong to the PLAYER. `seating[]`, `seatOf` and
|
||
`playerAtSeat` make the mapping explicit — it is still the identity mapping, so nothing moves yet.
|
||
|
||
The split immediately found a real bug: `awardDeparture` was indexing `s.players` with a seat. Correct
|
||
under the identity mapping, silently paying the wrong railroad under rotation, and unfalsifiable in
|
||
solitaire. That is what the change is for.
|
||
|
||
**Per-player turn state.** `turn: TurnState` became `turns: Map<PlayerIndex, TurnState>`, one per
|
||
player at phase entry, read through `turnOf(s, player)` at ~50 sites. **Behaviour-neutral by
|
||
construction** — the phase cursor still walks one player at a time, and every existing test passed
|
||
unchanged. It is done now rather than in Phase 2 because it is a wide, mechanical change to the engine
|
||
that costs almost nothing today and would be a rewrite once a wire format depends on the shape. What
|
||
it buys later is players acting in parallel where the rules allow it, which is where the latency in a
|
||
turn-based game over the internet actually lives.
|
||
|
||
**The page renders from `Frame` + `Menu` alone.** `main.ts` had eleven reads through into `GameState`
|
||
— the deck, the RNG, other players' hands — each one a thing a server would never send. `Frame` grew
|
||
`option`, `status`, `outcome`, `players[]` and `handCount` to cover them. A test now reads `main.ts`
|
||
and fails if a reader comes back, because the cheap moment to catch that is now and not in Phase 2.
|
||
|
||
**A `Session` between the page and the game.** `LocalSession` runs the engine in the browser exactly
|
||
as before; a `RemoteSession` will hold no authoritative state at all — it cannot, having neither the
|
||
deck order nor the other hands. So the interface is deliberately the smaller of the two, and what only
|
||
a local session can do (undo, local save, dealing a new game) is declared in `capabilities`, which the
|
||
page reads to hide those controls rather than calling them and failing. `submit` is async even though
|
||
the local one answers immediately: a page written against a synchronous submit would have to be
|
||
rewritten for the server.
|
||
|
||
`test/session.test.ts` is the proof — 13 tests, the first of which plays seed 77 to a finish through
|
||
both routes and asserts the boards and the histories are identical.
|
||
|
||
**Docs.** `protocol.md` was written from the rules before the engine existed and had become a second,
|
||
drifting copy of `intents.ts` and `events.ts`; it now points at them and keeps only what the types
|
||
cannot say — what is deliberately *not* an intent, the two loops a client must not flatten, and the
|
||
redaction surface. Three architecture documents still said WebSocket where D5 chose SSE + POST; fixed.
|
||
|
||
**Then a review found the seat/player audit was half done, and it was right.** Two flagged sites, and
|
||
sweeping for the pattern found six more. All of them are correct today and all of them break the
|
||
moment Employee Rotation lands, which is exactly the failure mode the split was supposed to end.
|
||
|
||
*Keyed an Office Area by player when `officeAreas` is keyed by seat:* `refusesThisOffice`
|
||
(`apply.ts` — the Crack Limited's terminals-only rule), `spendDispatchBonus` (`advance.ts` — the
|
||
Fedora is held by a PLAYER, the Telegraph is installed in a SEAT), `impediments` (`narrate.ts`), and
|
||
two district lookups in the bot. `adTrackCount(s, player)` in `narrate.ts` was passing a player into
|
||
a `SeatIndex` parameter — the type said seat and the caller said player, and TypeScript cannot tell
|
||
two `number` aliases apart, which is precisely why this needs a test rather than a type.
|
||
|
||
*Read seat 0 regardless of the viewer:* the bot's `takingRank` ranked a face-up Office card against
|
||
**seat 0's** Office tier for every player, so in a multi-player bot game everyone chased player 0's
|
||
upgrade. `stats.ts` and `save-replay.ts` also index seat 0, which is correct — they are solitaire
|
||
summaries — and now say so through `areaAtSeat` instead of looking like the same bug.
|
||
|
||
**And `Frame` was seat-scoped in the board and the hand but not in four fields beside them.**
|
||
`revenue`, `moves`, `blocked` and the pace note all still reported player 0. A player would have been
|
||
shown someone else's score, someone else's Moves remaining, and someone else's jammed facilities —
|
||
that last one a list of squares that are not on the board they are looking at. Worse, the crew lookup
|
||
was keyed by `row,col` alone while every district shares an origin, so a crew standing at (0, 1) in
|
||
one Office Area was drawn at (0, 1) on **every** player's board.
|
||
|
||
Five new tests, all of which fail against the previous commit: per-seat Revenue/pace/impediments,
|
||
crews staying on their own board, a full 3-player game played with `seating` rotated before the first
|
||
move, impediments following a player to their new chair, and a source-level guard that fails if
|
||
anything outside `state.ts`/`apply.ts` touches `officeAreas` directly — the class, rather than the
|
||
eight instances of it found by hand.
|
||
|
||
Bot unchanged at 7.0 mean over 200 solitaire Standard games (the bot fixes are behaviour-neutral with
|
||
one player), and all four published replays still play to the end — which, for a change of this width,
|
||
is the whole report.
|
||
|
||
## 0.3.1 — 2026-08-13
|
||
|
||
### Groundwork for multiplayer
|
||
|
||
Two steps that hold whichever way the hotseat-or-server question goes, plus a deck change.
|
||
|
||
**The 22 opponent-directed cards are out of every deck, not just solitaire's.** Q6 removed them from
|
||
solitaire because they have no legal target with one player; they are out of the competitive deck now
|
||
too, because `checkPlay` answers both categories `NOT_IMPLEMENTED` and dealing them would make ~9% of
|
||
draws reject outright. `buildDeck` returns 213 in both modes. `DECK_SIZE` (235) is now documented as
|
||
the CATALOGUE rather than the size of any deck in play — the two had quietly become different numbers.
|
||
Three Enhancements (Facing Point Locks, Water Column, Overpass) exist only to answer these cards and
|
||
stay dormant until they return; recorded in `TODO.md` as multiplayer work.
|
||
|
||
**A net under the multi-player engine paths** — `test/multiplayer.test.ts`, 13 tests. Everything else
|
||
in the suite is solitaire, so these had been running unwatched. They cover 2/3/4-player games playing
|
||
to a finish; per-seat Office Areas and Crew Tray counts; the Fedora passing to the *next* seat, only on
|
||
a shift boundary, reaching every seat over a game; Subdivisions splitting at the Offices that have
|
||
upgraded and not at the ones that have not; one player's oncoming train barring another's departure,
|
||
and no longer barring it once a Control Point puts them in different Subdivisions; and that
|
||
`awardDeparture` pays the Office that ran the train rather than seat 0 — which with one player was
|
||
unfalsifiable.
|
||
|
||
Two failed when first written and both times the TEST was wrong, not the engine: the Superintendent
|
||
cases drove the clock by calling `advance` in a loop, which never moves it — `advance` stops and asks
|
||
for input, so the game has to actually be played. **The engine's multi-player paths passed everything
|
||
on the first honest run**, which is the useful result here.
|
||
|
||
**`snapshot` and `actionMenu` take a seat.** They were hardcoded to player 0 in eight places across
|
||
`view.ts`, `game.ts` and `main.ts` — correct with one player, and a quiet disaster with more: every
|
||
seat would have been shown player 0's railroad *including player 0's hand*, which is the one thing the
|
||
state model calls secret. All eight now take a viewer, defaulting to 0 so solitaire and every replay
|
||
are untouched, and `describeIntent` now describes an intent against the acting player's district
|
||
rather than seat 0's. A test shows three seats getting three different boards and three different
|
||
hands, so the parameter is exercised rather than merely present.
|
||
|
||
Bot unchanged at 7.0 — solitaire never dealt the opponent cards, so none of this moves it.
|
||
|
||
## 0.3.0 — 2026-08-12
|
||
|
||
### Sharp curves are out of the deck
|
||
|
||
Eight cards, dealt zero copies. The only thing that made a sharp curve different from an ordinary one
|
||
was `moveCost: 2`, and nothing ever charged it — every switching move costs exactly 1, hard-coded — so
|
||
they were geometric duplicates taking eight draws from a deck the rebalance already considers too
|
||
diluted. `track.test.ts` even had a case called *"a sharp curve differs from a curve only in the Moves
|
||
it costs"* which asserted the two were identical, describing a difference that did not exist.
|
||
|
||
Jesse's call: take them out rather than build the Move cost, since a per-card movement cost is a
|
||
change to the Move model and the rebalance can wait. The rows stay in the catalogue at zero, exactly
|
||
as Poling does, so the design is still visible and the geometry still works if they are ever dealt
|
||
again. **Deck 243 → 235, track 104 → 96, solitaire 221 → 213.**
|
||
|
||
Four tests broke on the new deck composition and none of them was wrong about the game — all four
|
||
were resting on a single seed or a threshold that had drifted, so all four are now measured properly:
|
||
|
||
- **The oscillation detector ran on one seed.** Measured across sixteen: thirteen games show no
|
||
aimless shuttling at all and three reach a run of five. So it is a minority behaviour, not the
|
||
every-game waste the test was written for — it now asserts a rate (most games clean) plus a ceiling.
|
||
- **Interlocking reaches the board in 7/60 games, down from 15/60.** The bot has been pulled toward
|
||
other work by departure Revenue and spends its opening on the track it was dealt. Floor lowered to
|
||
match, and recorded as drift rather than quietly restated.
|
||
- **The action list reaches 9 buttons, up from 8** — all of them Switching, because the opening deal
|
||
grew districts from 17.9 to 20.3 cards and a crew has more squares it can legally reach. The list
|
||
getting longer for a good reason, not the cross-products that test was written to kill.
|
||
- **A test hoped seed 111 would deal an Office upgrade.** It now puts one in hand.
|
||
|
||
Bot after all of it: **7.0 mean, median 5.0, wins 5/200**, districts 18.8 cards.
|
||
|
||
### The special-train rules are enforced
|
||
|
||
All nine. They had been declared on `TrainRules` and read by nothing, so a Crack Limited could shunt
|
||
an industry and a Military train could be worked by Porters — the restrictions are what make a
|
||
special train special, and none of them applied. Each is checked where the act happens, and a LOCAL
|
||
CREW (`trainNumber: null`) is exempt from every one of them: it has no card, so it has no rules.
|
||
|
||
- **noSwitching** (both expresses, Light Engine, Campaign, Circus, Military) — moving, setting out
|
||
and sorting are all refused. All three are switching.
|
||
- **dropOnly** (X13 Appleseed) and **pickUpEmptiesOnly** (X22 Pee-Dee) — enforced on the MOVE, not on
|
||
a coupling action, because coupling is mandatory (§A.4): there is no running over cars and leaving
|
||
them, so the restriction has to bite on the move that would pick them up or it cannot bite at all.
|
||
- **oneFreightPerLocation** (trains 3/4 Express) — a budget per SQUARE rather than per turn, which is
|
||
what "one freight car at every location" says: the Express works a car here, moves on, works
|
||
another there. Dropping and picking up share the one budget, because the card says "drop OR pick
|
||
up". Kept in `turn.freightWorked`, keyed by tray and square, cleared with the turn.
|
||
- **noPassengerWork** (Military, Director's car) and **terminalsOnly** (Crack Limited) — Porters
|
||
refuse the train. `canBoard`/`canDetrain` now filter the trains at the Office rather than taking
|
||
any of them, so the rule actually bites; a new `passengerRefusal` works out whether a card is the
|
||
reason and says which, because "no train at the Office" was both wrong and unhelpful when there was
|
||
a train standing right there.
|
||
- **stopThenExpedite** (X17 Campaign) — the speeches happen at the first Office it reaches: that
|
||
arrival is an ordinary stop, and every arrival after it is expedited. A `speechMade` flag on the
|
||
tray, alongside `stopPointClaimed`, for the same reason — it is the TRAIN that stops, and an Extra
|
||
runs once.
|
||
|
||
**`coachStaysOnStationTrack` could not be built as intended, and the reason is worth recording.** The
|
||
chosen reading was "the coach may only be set out at the Office", but §A.4 refuses the Office square
|
||
to *every* drop — "the Office track is Operational Rail, but Rolling Stock may not be left there" —
|
||
so "only at the Office" and "nowhere at all" are the same rule. It is enforced as the effect that
|
||
survives: the Local may shunt its freight car around the district and may not abandon its coach doing
|
||
it. If the station track is meant to become a real place to leave a coach, that is a change to §A.4
|
||
rather than to this card; noted in `TODO.md`.
|
||
|
||
**`copiesNextScheduled` was deleted rather than implemented.** No train card ever carried it: a
|
||
Second Section is a Maneuver card played on a train due out, with its own intent
|
||
(`newTrain.secondSection`) that has worked all along. It was an unreachable second description of a
|
||
mechanic that already existed, and it had been sitting in the "nine rules read by nothing" count as
|
||
the one entry that needed removing.
|
||
|
||
**Cost, measured over 200 games: revenue 8.0 → 7.2 and wins 14/200 → 6/200.** Restrictions make the
|
||
game harder, which is the point — but it is worth knowing that enforcing §7 took back about a sixth
|
||
of what the departure-Revenue rule gave. The train tooltip no longer says "NOT YET ENFORCED BY THE
|
||
ENGINE"; it says what each rule does, because the restriction is the character of the card.
|
||
|
||
### Two rule changes, both provisional, both measured
|
||
|
||
**The opening deal is now 3 track + 3 other, from two separately shuffled piles.** Track is shuffled
|
||
apart from the rest, each player is dealt three of each, and the leftover track is shuffled back in
|
||
for the rest of the game — the split is an opening-deal device only. A player opens holding six
|
||
against a hand limit of three, so the first turn is spent choosing which district they can afford;
|
||
§6.2 already permits "any or all" cards to be played in a turn and puts no cap on discards, so the
|
||
reduction can always be made and the engine needed no special first-turn case.
|
||
|
||
This is the answer to the run-around problem, which was measured five ways and is a SUPPLY problem:
|
||
a turnout and a matching-hand curve were in hand together on 0.2% of turns, about once every eight
|
||
games. **It worked, modestly** — run-arounds **4/60 → 7/60**, districts **17.9 → 20.3 cards** — with
|
||
revenue unmoved on its own (−0.1, inside noise). Nowhere near the 91/100 of the private-supply era,
|
||
so the supply question is softened rather than answered.
|
||
|
||
**One Revenue for every train that clears your section.** Paid when a train is highballed out of your
|
||
Office, once per train, and not again when it later runs off the end of the Division — that far
|
||
Division Point belongs to whoever is seated at it. A local switching crew has no train number and
|
||
earns nothing. Worth exactly **+5.40 a game**: departures run at 5.40 and each pays 1. The bot goes
|
||
**2.7 → 8.0 mean, median 0 → 6.5, wins 2/200 → 14/200**. Unlike freight and passengers it pays for
|
||
traffic the player does not have to work, which is the point — keeping the line clear is the
|
||
Superintendent's job and nothing paid for doing it well. It also makes the victory target live: 20
|
||
over 5 Days is now reachable largely on traffic.
|
||
|
||
**A regression that came with the deal, recorded rather than hidden.** Forced to shed on turn one,
|
||
the bot lays pieces it should discard: track butting a card that cannot accept it went from 7% to
|
||
15%. A player would simply discard the ones with nowhere good to go. The regression floor in
|
||
`sim.test.ts` was raised from 0.15 to 0.25 with the reasoning written in, and it is in `TODO.md` as
|
||
bot work for after the rebalance. It also means part of that district-size gain is padding.
|
||
|
||
**Found while fixing the tests this broke.** Three of them were passing on luck rather than checking
|
||
anything, and the deal change exposed all three at once: the commodity test asserted a tank car is
|
||
set out somewhere in 30 games when it happens in **3% of games**; the replay-sound test asserted six
|
||
cue kinds from a single seed when the bot couples in only **20%** of games and sets cars out in 43%;
|
||
and two web tests depended on a seed happening to deal a particular card. All four are now decisive —
|
||
pooled seeds, a sample sized to the base rate, or the card put in hand deliberately. Separately,
|
||
`moveTrain`'s branch for departing an Office straight onto a Division Point turns out to be
|
||
**unreachable**: `buildDivision` always lays `DP · Mainline · Office · Mainline · … · DP`, so an
|
||
Office is never adjacent to one. It is kept correct rather than deleted, and `setup.test.ts` now
|
||
asserts the flanking invariant that makes it dead.
|
||
|
||
All five published replays were re-recorded: the deal changes what every seed deals, so the previous
|
||
recordings died at 2 intents of ~350.
|
||
|
||
### Nine things playtesting found
|
||
|
||
Eight fixes and one new rule, all from one session at the table. Two of the nine turned out not to be
|
||
bugs at all, and saying why is most of the work in those.
|
||
|
||
**Left and right were on the wrong diagonal — for turnouts and for curves, the same way.** The engine
|
||
called a card `left` when a train entering at its points saw the diverging route leave to its
|
||
**right**: `{stem:'w', through:'e', diverge:'s'}` heads east, and south is the driver's right. Curves
|
||
inherited the same inversion, because a curve takes its hand from the turnout whose leg it continues.
|
||
Every track card a player has ever held was labelled as its mirror. **The artwork was never wrong** —
|
||
`board-svg.ts` draws rails from `connectionsFor` geometry alone — so this is words only, and the fix
|
||
flips the `hand` field on the `TRACK_CARDS` rows together with `CURVE_VARIANTS` and
|
||
`TURNOUT_VARIANTS`. The row ORDER is deliberately left alone: `setup.ts` builds the deck by walking
|
||
that array, so a row's position decides which physical card a seed deals, and reordering to look tidy
|
||
would silently re-deal every published replay. Verified by fingerprinting seed 1234's whole track
|
||
deck before and after — 101 cards, byte-identical, and the three replays that survived the rest of
|
||
this work prove it in practice.
|
||
|
||
**A turnout can now be laid on top of a card already down.** Reported as: a district can only hang off
|
||
a turnout, so a player who laid a straight along the main and then wanted to branch there had no move
|
||
at all — the piece had to have been a turnout when it went down. A turnout may now upgrade a straight
|
||
at any rotation, or a curve whose arc matches its own diverging leg. Both are strict port supersets of
|
||
what they replace, so an upgrade can never sever a join a neighbour relies on and needs no connection
|
||
test; the new leg may reach nothing, which is the point. Blocked on a standing car (`UPGRADE_OCCUPIED`)
|
||
and on a built Interlocking or Telegraph (`UPGRADE_ENHANCED`) — both about the card being in use rather
|
||
than its shape. The rule is stated in ARCS rather than hands so the flip above cannot reach it. The bot
|
||
found and played it 40 times across 40 games without being taught anything.
|
||
|
||
**A Depot showed three MEN | AT | WORK boxes it has no Laborer to work.** An Office is a Passenger
|
||
Facility and the sign is printed "For Freight Facilities" (§9.1), but every facility got a three-slot
|
||
array of nulls and the renderers loop it — the green and red rows either side *were* guarded and the
|
||
MAW row was not, in all three renderers. Fixed in the MODEL rather than the renderers: `menAtWork` is
|
||
now `null` on a passenger facility, which is what the "Freight only" comment on that field has claimed
|
||
all along. The type flushed out every reader, freight work is now refused on the shape of the facility
|
||
rather than on it happening to hold 0 Laborers, and a fourth renderer cannot reintroduce the bug. Two
|
||
more artifacts of the same "an Office is a Facility" modelling went with it: a Depot read
|
||
**SHIPS + RECEIVES**, an industry's flow word, and was drawn a siding square although its industry
|
||
track has length 0.
|
||
|
||
**The side panel painted inbound loads green.** Its shared box helper used one class for every filled
|
||
box, so the red row rendered green while the board SVG on the same screen drew it red — two views of
|
||
one card disagreeing about the colour code at the same moment. Green is outbound everywhere now, red
|
||
inbound, grey for the siding, which is a place rather than a direction.
|
||
|
||
**The card just drawn now sits first in the hand, badged NEW.** The engine pushes onto the end, which
|
||
with a wrapping row put the card you just turned over wherever the eye is least likely to be, among
|
||
two others that look exactly like it. Reversed in the DISPLAY — `actionMenu` for the play page and
|
||
`snapshot` for both replay viewers — and deliberately not in the engine: the bot iterates its hand to
|
||
generate options, so moving the stored order would reshuffle its tie-breaks and invalidate every
|
||
revenue figure in `TODO.md`. Confirmed: 2.8 before, 2.7 after, over 200 games.
|
||
|
||
**Two Ice Houses could be built in one district.** Industries have been barred from doubling up since
|
||
Q4, but a Modifier is a different card kind and had no such check at all. One of a kind per Office
|
||
Area now. Enhancements are deliberately left alone — an Interlocking is a plant at one junction, so a
|
||
second on another straight is a different installation. This is the change that killed a published
|
||
replay: `seed-4894942` had recorded a game that played two Waiting Areas, so it was a recording of
|
||
illegal play and has been re-recorded.
|
||
|
||
**NOT A BUG — "the Ice House added the laborer but not the outbound slot".** The card prints +1
|
||
*outbound*; a Grocer's Warehouse is `flow: 'inbound'`, so `allows.outbound` is false and the capacity
|
||
was being raised on a direction that can never be drawn or stocked. The laborer landed because
|
||
Laborers have no direction. An industry's printed flow stays absolute — no modifier turns a receiver
|
||
into a shipper — so the grant is now dropped rather than credited, and the reason is said out loud in
|
||
both directions: the card in hand names which of its printed hosts cannot use which half, and the
|
||
facility panel reports a suppressed bonus instead of quietly showing a number that did not move. The
|
||
same trap catches Truck Dock and Forklifts on a Grocer's, and Waiting Area, Restaurant and Hotel on a
|
||
Whistle Post. Two things fell out of this: `TrackCard.modifiers` was initialised everywhere and
|
||
appended nowhere, so the panel's "Modifier cards standing beside this industry" row had always been
|
||
empty; and a Waiting Area was lengthening the Office's `industryTrack`, which drew a Depot a siding to
|
||
spot cars on — the phantom siding above, arriving by another door. That one was found by rendering
|
||
12,000 frames of real games, not by a fixture, and it is why the smoke pass exists.
|
||
|
||
**An Interlocking on the board was a bare label.** It now carries hover text from the card catalogue,
|
||
and the one card nothing reads says so. **Seven of the ten resolve in play**: the dispatch chain
|
||
(Telegraph, Telephone, Radio) plus Interlocking, which holds an arrival at the Limits when the Office
|
||
is full; Yard Office, which diverts a coachless train; Small Yard, which re-orders a consist; and ABS
|
||
Signals, stored on the Mainline node rather than in `enhancements[]`. Facing Point Locks and the Water
|
||
Column are wired and read but answer opponent-directed cards a solitaire deck omits. **Overpass alone
|
||
has no code path anywhere**, and is the only one that carries the warning.
|
||
|
||
**Got this badly wrong first time, and it is worth recording how.** The table was filled in by
|
||
grepping for four helper function names — `dispatchBonus`, `hasDistrictEnhancement`,
|
||
`isProtectedFromDerail`, `watertowersRemovable` — and reading "no match" as "no implementation". But
|
||
Interlocking, Yard Office and Small Yard are read *by key* in `advance.ts` and `apply.ts`, and ABS
|
||
Signals through `node.absSignals`, so five of ten statuses were wrong and the tooltip told players
|
||
that four working cards did nothing — worse than the bare label it replaced. `enhancements.test.ts`
|
||
had covering tests for all four, passing, the whole time. The test written to guard the table made it
|
||
worse rather than catching it: it asserted `effect === 'live'` if and only if the rule carried a
|
||
`dispatchBonus`, which restates the assumption that produced the error instead of checking it. It now
|
||
asserts the three sets by name, and every row cites the file that reads it, because a status without a
|
||
citation beside it is a claim nobody checked.
|
||
|
||
### Three rules read back from the sheet
|
||
|
||
Jesse's follow-up after the above. Two were already right, which is worth recording so nobody
|
||
re-investigates them; one was a real gap that had been sitting in plain sight.
|
||
|
||
**Clearance asked about the next CARD, not the next Subdivision.** §8.1 is explicit — "if there is a
|
||
train in the next **Subdivision** moving towards the considered train, the considered train will not
|
||
depart" — and `evaluateClearance` only ever inspected `node.transits`, the single card being entered.
|
||
A train ran headlong into a Subdivision an opposing train was two cards deep in and was stopped only
|
||
on the Stage they actually met. `subdivisions()` had been sitting in `state.ts` for this the whole
|
||
time, called by nothing outside a test. Red Flags and ABS Signals now read the card the OTHER train
|
||
stands on rather than the card being entered; those were the same card while this only looked one
|
||
ahead, and the protection belongs where the train it protects actually is. Worth knowing how much
|
||
this bites: every Office starts as a Whistle Post, so the whole railroad is ONE Subdivision until
|
||
someone upgrades, and each upgrade to a Control Point splits one in two and buys capacity back. The
|
||
bot is unmoved (**2.7 both sides, 200 games**) because solitaire schedules only ~1.3 trains a game —
|
||
this is a rule that earns its keep with 2–5 players.
|
||
|
||
**Straight-placed enhancements replace the straight, so they no longer stack.** An Interlocking's
|
||
printed placement is "any Running Track Straight": it goes down IN PLACE OF the straight, and what
|
||
stands there afterwards is an Interlocking, not a straight carrying one. Nothing checked it — an
|
||
enhancement only pushes a string onto `enhancements[]` and leaves the geometry alone — so a single
|
||
straight could hold Interlocking, Telegraph and a Water Column at once. **The Telegraph → Telephone →
|
||
Radio chain is untouched and is not an exception**: Telephone prints "on Telegraph" and Radio "on
|
||
Telephone", so those target a named card rather than a straight, which is exactly why they still
|
||
stack. The printed placements settle it; `requiresOnSameCard` already enforced it.
|
||
|
||
**ALREADY CORRECT, twice.** Control Points and Subdivisions were modelled properly — `isControlPoint`
|
||
is false only for a Whistle Post, and `subdivisions()` splits between them — so only the clearance
|
||
*use* of them was missing. And §8.2's "moved immediately to the Office, where it stops for orders" was
|
||
already the behaviour: `arriveAtOffice` returns `moved`, and the caller adds the train to
|
||
`movedThisPhase`. The one exception is deliberate and recovered — Q3, an expedited train departs the
|
||
Stage it arrives, and gets a second `moveTrain` still subject to §8.1.
|
||
|
||
**A missing §8.1 condition, restored to the ruleset.** Jesse read the highball conditions back off the
|
||
source sheet and the first one — *"the train did not just initially arrive from a mainline card at the
|
||
Office this Stage"* — was absent from `rules-v0.2.md` entirely. The BEHAVIOUR was right, but only as a
|
||
side effect: the Mainline Phase visits each train once per Stage in numeric order, so a train that
|
||
spends its visit arriving has no visit left to depart with. Nothing anywhere stated the rule, which
|
||
makes it precisely the sort of property a later change to that loop breaks in silence. Now written
|
||
into §8.1 and pinned by tests, together with the observation that **this is the condition the printed
|
||
`Expedite` rule exists to override** — without it, `Expedite` was a special case with no general rule
|
||
to be special against, which is why Q1/Q3 found it so hard to place. All six conditions now have
|
||
coverage, including the Gap 2b carve-out: a train standing clear on Secondary Track in a Whistle Post
|
||
district must not hold up the Subdivision, which the new subdivision-wide scan had to be careful not
|
||
to break.
|
||
|
||
**Verification.** 438 tests (up from 416) and a clean `tsc`. Beyond the unit tests: the track deck
|
||
fingerprint before and after the flip; all published replays replayed end to end; the bot baseline
|
||
re-measured against a worktree at HEAD; and 40 real games rendered frame by frame through the same
|
||
functions the page calls — 12,169 frames, which is where the Waiting Area siding surfaced. Not done:
|
||
nobody has clicked through this in a browser.
|
||
|
||
## 0.2.0 — 2026-08-09
|
||
|
||
### The bot plays twice as well, and a rule it was never following
|
||
|
||
**Adopted, after measuring: cap the trains at the Office's A/D capacity, and operate before
|
||
drawing.** Together **+1.52 ± 0.17 (t = 9.16)** over 1600 paired seeds — revenue **1.19 → 2.72**, wins
|
||
11 → 21 in 1600, collisions 0.38 → 0.02. Both are now default play; the flags that carried them are
|
||
gone, and what remains in `BotTweaks` are two ABLATIONS that turn them off, because the question a
|
||
measured heuristic needs later is "is this still true?" rather than "does this help?".
|
||
|
||
The six candidates that measured neutral are deleted rather than left switched off. Adding all of
|
||
them on top of the two winners was worth **−0.08**: +1.44 against +1.52 without.
|
||
|
||
**Jesse's idea — build first, then switch — was right about the axis and wrong about the half that
|
||
mattered.** Reserving Day 1 for development measures +0.52; reserving none and simply preferring the
|
||
work to the draw measures +0.48 with a far better spread (317 seeds better against 77, where the
|
||
build phase gives 329 against 144). With the cap alongside, no build phase beats one Day beats two.
|
||
So the keeper is "operate rather than draw", and the building phase — the intuitive part — is inert:
|
||
the draw-priority ordering was already developing the district well enough.
|
||
|
||
It works through the constraint the funnel had been pointing at all along: green-stocked Cargo phases
|
||
+60%, freight revenue +39%.
|
||
|
||
**And then the same tooling proved that constraint was the wrong one.** Stocking green boxes
|
||
speculatively — filling them before a car is spotted — raises stocked phases **five-fold**, from 5.6
|
||
to 28.8 of 60, and moves freight revenue by **nothing at all** (1.16 → 1.15, and −0.10 revenue
|
||
overall, t = −3.62). The green box was never the gate. The gate is **the spotted car**, and the 8%
|
||
figure was low because stocking is *conditional on* a car being there — it was measuring the car
|
||
supply all along. Switching quality, not Freight Agent turns, is where the freight economy is won.
|
||
|
||
### The engine was not following two rules it prints
|
||
|
||
Chasing the Rolling Stock census found the cause, and it is not a modelling ambiguity after all.
|
||
§9.3 says an unload requires "*an empty car of that type in the Division Yard*" and §9.2 says
|
||
de-training requires "*a white empty coach in the Division Yard*". **Neither requirement was checked,
|
||
and both reducers conjured the replacement car instead of taking it** — so every unload and every
|
||
de-training minted a car, 1.29 a game against a supply of 80.
|
||
|
||
Both now take the car from the Division Yard, as the rules say. The census is conserved: censused
|
||
after every batch across 80 games, **nothing changes it but a collision**, where before it drifted to
|
||
119 against 80. A test does that check now, because this class of bug produces no symptom until a
|
||
supply number is tuned against it.
|
||
|
||
It costs the bot about 0.09 revenue — unloading is meant to consume supply — and that is the point:
|
||
`ROLLING_STOCK_SUPPLY` can be tuned against reality now.
|
||
|
||
### Where the game actually stands
|
||
|
||
With a bot that no longer wastes revenue, the balance question resolves. Revenue is linear in trains
|
||
scheduled at about **1.9 a train**, and trains are capped by A/D capacity, which is the Office tier,
|
||
which is a card you have to draw. 37% of games never leave the Whistle Post; **53% earn nothing at
|
||
all**; the median game scores 0 and the best of 800 scored 26.
|
||
|
||
Twenty Revenue would need roughly **eleven trains and eleven A/D tracks**. A Terminal has four. The
|
||
target is not missed, it is unreachable — and that is now a deck question with numbers behind it
|
||
rather than a suspicion. `TODO.md` carries the three ways out.
|
||
|
||
### Five ways to teach the bot to plan a siding, and why none of them can work
|
||
|
||
Asked directly whether the bot could think across turns and stop building dead-end stubs. It can be
|
||
taught to; it does not help, and finding out why was worth more than the attempts.
|
||
|
||
| attempt | result |
|
||
|---|---|
|
||
| hold ALL track for the siding | **−0.70** (t = −3.27) |
|
||
| hold only CURVES — the only piece that can climb back to the main | **−0.26** (t = −3.22) |
|
||
| finish an open run before cutting another way down | 0.00 — 398/400 identical |
|
||
| treat a second turnout as the piece that closes the loop | 0.00 — **400/400 identical** |
|
||
| spend a curve only on a square that actually closes a run | −0.11, 15 games in 400 differ |
|
||
|
||
**The pieces never meet.** Over 12,000 Local Operations turns, a turnout and a curve are in hand
|
||
together on **0.3%** of them, and a turnout with a MATCHING-hand curve on **0.2%** — about once every
|
||
eight games. A run-around needs five specific pieces of the right hands arriving in a usable order,
|
||
and the bot does not reach the two-piece prerequisite, let alone the fifth.
|
||
|
||
**It is not hand pressure**, which is what the first two attempts assumed. The hand is full: mean
|
||
2.66 cards, at the three-card limit on 78% of turns. The bot plays 11.4 track cards a game against
|
||
1.5 discarded, spending each piece as it arrives because a piece that builds something now outscores
|
||
holding one that might build more later — and the measurements say it is right to.
|
||
|
||
So the dead-end stubs are not a planning failure. They are what a five-piece structure looks like
|
||
when the pieces are drawn one at a time from a 243-card deck: 91 run-arounds per 100 games when
|
||
track was a private supply the player chose from, 29/100 once track was drawn, 4/60 today. `TODO.md`
|
||
carries the options, and all of them are deck changes rather than bot changes.
|
||
|
||
### Ten ways to make the bot better, and one of them works
|
||
|
||
Every heuristic below was measured paired over 400+ seeds, and confirmed at 1600 before being
|
||
believed. Nothing is adopted yet — every flag is off, so the bot plays exactly as it did.
|
||
|
||
**The only thing that moves revenue is refusing to schedule a train the Office cannot hold.**
|
||
`trainCapSlack=0` — never more committed trains than A/D tracks — is **+1.09 ± 0.16 (t = 6.79)**,
|
||
taking the bot from 1.19 to 2.28 mean. Every other reordering of the bot's preferences measured
|
||
inside the noise. Adding all six of the small ones on top of the cap is worth a further 0.10.
|
||
|
||
**Three failures worth more than the success.**
|
||
|
||
*Refusing to bury the engine costs 0.35 a game* (t = −2.98). The tweak works perfectly — burial
|
||
falls from 8.4 decisions a game to 0.03 — and freight halves along with it. Coupling is mandatory
|
||
(§A.4), so **the moves that bury the engine are the moves that pick cars up**. Burial is the price
|
||
of collecting, not a mistake to be coached out. The refinement that only refuses to ARRIVE at the
|
||
Office buried is +0.16 and inside the noise.
|
||
|
||
*Reserving Moves to get home costs 0.55* (t = −2.32), even though 62 of the 120 trains still on the
|
||
board at game end were stranded in the district, unable to depart from anywhere but the Office. The
|
||
switching work is worth more than the departures it forfeits.
|
||
|
||
*Granting clearance when the train ahead has one Stage left costs 0.98* (t = −5.24) — a clean
|
||
rejection of a rule that looked safe. Q13 collides on catching up, so a follower let onto a card
|
||
whose occupant is leaving "cannot" catch it — except trains move in numeric order, so the follower
|
||
can enter the region the leader still occupies before the leader has moved. **"About to leave" is
|
||
not "gone".**
|
||
|
||
**And three exact no-ops, each for a different reason.** Playing Interlocking ahead of a train card
|
||
changed nothing because the two sit in hand together **0.04 decisions a game**. Stocking the Office
|
||
platform first changed nothing because the bot already chooses it 79% of the time. Spending an idle
|
||
turn on the Freight Agent changed nothing because it asked the same predicate a branch three steps
|
||
earlier already acts on — a tautology, and 400/400 identical games is what one looks like.
|
||
|
||
The funnel explains the pattern: **8% of Cargo phases have a stocked green box**, and the bot already
|
||
takes 42% of the turns where stocking is productive. There is nothing to prioritise better. What
|
||
remains is the economy itself, which is a deck question rather than a bot one.
|
||
|
||
### A conservation audit, and the bug it found
|
||
|
||
**Clearing an inbound box mints a car — 1.29 a game against a supply of 80.** `TODO.md` had this as
|
||
an open question ("no way to tell a load from a car"); it is duplication, and the two directions are
|
||
not symmetrical:
|
||
|
||
- **Outbound is paid for.** `stockToOutbound` splices a loaded car OUT of the Division Yard to become
|
||
the load, and `loadCompleted` banks the emptied car as the loaded one takes its place.
|
||
- **Inbound is not.** `unloadBegan` turns one loaded car into an empty car *plus* a load on
|
||
MEN|AT|WORK, `passengersDetrained` does the same to a coach — and `inboundCleared` then pushes that
|
||
load into the **Classification Yard as a car**, while the car it came out of is already back in
|
||
service.
|
||
|
||
Over 200 games, cars-at-the-end minus 80 plus collision losses equals the `inboundCleared` count
|
||
exactly in 71/200 games and 258 against 279 overall; the remainder is loads still in flight at the
|
||
final whistle. Since `ROLLING_STOCK_SUPPLY` is the number that is supposed to set supply pressure,
|
||
no supply figure can be tuned until this is decided. Not fixed here — it is a modelling decision.
|
||
|
||
Also found: **`state = fold(events)` is not literally true.** Replaying the event log onto a fresh
|
||
state throws, because the phase driver mutates state directly and emits a descriptive event
|
||
afterwards. Replay works by re-applying intents, not by folding events. Nothing is broken today, but
|
||
the README claims the property and reconnection would rest on it.
|
||
|
||
### The replays were all dead, and now they cannot be
|
||
|
||
All three published replays managed **2 intents of roughly 400** — the site was serving three
|
||
recordings of nothing, exactly as `TODO.md` predicted would happen silently. `save-replay.ts` records
|
||
bot games as saves, and **verifies every one round-trips before writing it**: same revenue, same Day,
|
||
same intent count. `harness.test.ts` fails if any published replay stops short of its own history.
|
||
|
||
Six replays published, 18 to 23 Revenue, against a target of 20 and a bot median of 0 — including
|
||
one game with four collisions that still cleared the target, and two with none.
|
||
|
||
## 0.1.1 — 2026-08-08
|
||
|
||
### A way to tell whether a change to the bot helped
|
||
|
||
The bot averages **1.4 Revenue against a target of 20**, and the obvious next step is to teach it to
|
||
play better. That step could not be taken honestly, because there was no way to tell whether a
|
||
heuristic had helped: revenue has σ ≈ 9 across games, so two runs of the *identical* bot differ by
|
||
about a point through nothing but the deal. Every single-change claim in this changelog before the
|
||
Interlocking work is inside that noise, and `TODO.md` has said so for a while.
|
||
|
||
**`node src/sim/compare.ts 1600 trainCapSlack=1`** runs the current bot and one variant over the same
|
||
deals and reports the per-seed difference. Giving both sides the same seed takes the deal out of the
|
||
comparison: σ drops from ~9 on the level to **5.3 on the difference**, and 1600 seeds puts the
|
||
standard error at **±0.13** — in 1m45s. The noise floor moves from ±1.0 to about ±0.15, which makes
|
||
every heuristic in the training plan resolvable. No parallelism needed; 100 games take 4.8s.
|
||
|
||
**The report prints the better/worse/identical split beside the mean**, because they are different
|
||
claims. The first tweak measured is the case in point — `trainCapSlack=1` over 1600 paired seeds:
|
||
|
||
```
|
||
REVENUE DELTA +0.77 ± 0.13 (t = 5.97, σ of the paired difference 5.13)
|
||
seeds better 146 · worse 105 · identical 1349
|
||
best seeds: +75, +50, +46 worst seeds: -26, -20, -13
|
||
```
|
||
|
||
**84% of games are untouched.** It does not make the bot play better; it removes a rare catastrophe,
|
||
and the mean rides on a handful of rescued games. Reporting that as "revenue up 65%" would be
|
||
arithmetically true and misleading about what changed. At 400 seeds the same tweak read t = 2.57 —
|
||
"not proven" — which is exactly the verdict it deserved there.
|
||
|
||
The tweak is **measured but not adopted**: the flag stays off, so this commit changes no bot
|
||
behaviour. Turning it on is a change to how the bot plays and belongs in its own reviewed commit.
|
||
|
||
**And it prints the funnel for both sides**, because revenue can rise two ways: a channel started
|
||
working, or an expensive channel was abandoned for a cheap one. A strict train cap raises revenue
|
||
*and* cuts freight events nearly in half, and that has to be visible rather than inferred.
|
||
|
||
**The funnel is new** — `GameStats.funnel`, sampled live rather than recovered from the log, because
|
||
the interesting gates are conditions rather than occurrences. "Was a green box stocked while a car
|
||
was spotted" is not a thing that happens; it is true or false at a moment. It needed a per-decision
|
||
hook on `playGame` (`TurnObserver`), separate from the existing event observer, so a phase is
|
||
sampled once instead of once per event in the batch. What it says about the current bot:
|
||
|
||
- **Passengers:** of 7.4 arrivals a game, 70% reach a Passenger Facility, 25% carry the empty coach
|
||
boarding requires, 21% the loaded coach detraining requires.
|
||
- **Freight:** of 60 Cargo phases, a green box is stocked in **8%** and all three requirements meet
|
||
at one industry in **7%**. Freight is gated almost entirely on stocking.
|
||
- **Stuck:** 8.4 decisions a game are taken with a train's engine buried mid-consist, and on **3%**
|
||
of them is there a legal way to set the nose cars out — because it happens at the Office, where
|
||
Rolling Stock may not be left.
|
||
|
||
**`developerBot` is now `makeDeveloperBot({})`**, byte-identical to what came before (asserted on
|
||
three seeds by full event-stream fingerprint, and 1.4400 mean on 100 games either way). Variants
|
||
exist so two policies can be compared in one process rather than by editing the bot between runs —
|
||
which is how you end up comparing two things you cannot reproduce. Tweaks are temporary: a flag that
|
||
measures well becomes the default and is deleted in the same commit.
|
||
|
||
**The first tweak found the bug this tooling exists to find, twice over.** `trainCapSlack` caps
|
||
committed trains against the Office's A/D tracks. Written first as a gate on the two branches whose
|
||
comments say they exist to play a train card, it measured **exactly zero difference over 400 paired
|
||
seeds** — because `followThrough` ends with a generic "play what is in hand" fallback that played the
|
||
card anyway. A cap has to remove the option, not guard the branches that reach for it.
|
||
|
||
Then the *test* for it was wrong in the same shape: it asserted only that some decision changed
|
||
somewhere, and **passed against the broken bot**, because a tight cap does change which Local
|
||
Operations option gets chosen — it just fails to stop the card being played two steps later. It now
|
||
asserts the contract (committed trains never exceed what the Office can hold) and is verified to fail
|
||
against the broken version. "Something moved" is not the promise.
|
||
|
||
Also new: a determinism self-check. The same policy on the same seed must produce an identical event
|
||
stream, since the paired method rests on it and the failure mode is silent.
|
||
|
||
Tests 403 → 413.
|
||
|
||
## 0.1.0 — 2026-08-08
|
||
|
||
The first numbered build. Everything below the "second playtest pass" heading was made under
|
||
`0.0.1`, across twenty commits, which is exactly the problem the version convention above exists to
|
||
fix: "the current build" was only ever answerable by a commit hash.
|
||
|
||
### A third playtest pass — and three of the "display problems" were engine bugs
|
||
|
||
Fifteen items came back from two playtest sessions. Several of the ones that read like drawing
|
||
faults were not: the board was reporting the game accurately and the game was wrong.
|
||
|
||
**Cars could be added to a train that was not being made up.** `newTrain.placeCar` accepted any tray
|
||
with room in its consist, while the New Train Phase's own idea of the train it is waiting on —
|
||
`trainNeedingCars` — requires the tray to be **at a Division Point** and short of its card. So during
|
||
any New Train Phase the Division Yard would hand cars to a train standing on a siding in your own
|
||
district, or one halfway across the Division. Measured before the fix: **50 such offers across 8
|
||
solitaire games**, including Train 9 mid-crossing with three cars already aboard. That is the
|
||
"cars magically appeared on my train" report, and the answer to it.
|
||
|
||
The two questions — "is this the train being assembled?" and "may this car be added?" — now come from
|
||
one predicate (`isBeingMadeUp`, in apply.ts beside `check`), which is what stopped them disagreeing.
|
||
`newTrain.passCar` takes the same guard.
|
||
|
||
**The make-up panel merged two trains into one.** Two trains can be built in the same Stage — a
|
||
timetabled train and a Second Section, or an Extra — and the panel collected every option from every
|
||
tray, titling the result with whichever tray came first out of the map. Reproduced at seed 99,
|
||
Day 3 Stage 12: eighteen car chips under "Making up Train 8", covering two different trains. Worse
|
||
than the wrong caption: the Division Yard chip binds to the FIRST matching option, so clicking a
|
||
hopper could couple it to the other train. The panel is now scoped to the one tray the engine is
|
||
waiting on. This was reported as the history announcing Train 9 while the panel said Train 10.
|
||
|
||
**Upgrading the Office deleted Modifier bonuses.** `officeUpgraded` wrote the new tier's printed
|
||
numbers straight over the facility, so a Restaurant beside a Depot — +1 passenger out, +1 porter —
|
||
was silently erased by the next upgrade, after the card had been spent. The tier is applied as a
|
||
**difference** now, so the upgrade raises the Office by exactly what it is worth and leaves what is
|
||
standing beside it alone.
|
||
|
||
**A sentinel inside a coordinate's own value range.** ABS Signals goes on a Mainline card, and the
|
||
node index travelled as the fake coordinate `{ row: -1, col: node }` — but row −1 is an ordinary
|
||
district row, the first one below the Running Track, where most districts start. So ABS Signals
|
||
highlighted whichever district card sat at that column, and an ordinary Enhancement laid one row down
|
||
was described as being "out on the Mainline, node −2". `card.play` now carries `node` as its own
|
||
field and a Mainline placement has no coordinate at all; the board simply does not light up for it.
|
||
This was the other half of "why is my Depot highlighted?" — the first half being that Enhancements
|
||
legitimately attach to played cards, which nothing on screen said.
|
||
|
||
### The train, drawn the way it stands
|
||
|
||
**`consist` is ordered nose first, and both renderers drew index 0 leftmost whatever the train was
|
||
doing.** A westbound train therefore came out right and an eastbound one came out mirrored —
|
||
reported from seed 270861860, Train 10 running east and drawn engine-first at the WEST end, which
|
||
reads as an engine shoving its whole train ahead of it. The board is a map, so the drawing obeys the
|
||
map: west on the left, nose toward the way the engine faces. A crew on a north-south spur keeps
|
||
nose-left and lets its ▲/▼ say the rest.
|
||
|
||
**The Division chip draws the train now.** It was a name and a figure, and the figure was Stages left
|
||
to cross — read once as the car count, and once it was labelled `· 2⧗`, read as redundant beside the
|
||
position the card already draws. It is: the region position is DERIVED from that countdown. So the
|
||
chip carries the consist instead, in the same vocabulary the district card uses, and the countdown
|
||
moved into the tooltip where there is room to say what it is. Stages are not regions — a card is two
|
||
regions of fixed distance, and how many Stages a train needs over them depends on the card speed and
|
||
the train (`crossingStages`); they coincide only for a 30 card and a normal train.
|
||
|
||
**Loaded or empty, told the same way in both places.** An occupied slot on a district card took a
|
||
generic blue fill and only a LOADED car overrode it, so at 26×15px empty read as blue-ish and loaded
|
||
as brown-ish — while the tray beside it made the same distinction unmistakable. Reported as "I left a
|
||
car in a siding and now I can't remember if it was loaded". The card now matches the tray, and every
|
||
slot and every car in a consist carries its own words as a tooltip.
|
||
|
||
### Saying what the game already knew
|
||
|
||
**Why a switching move is not offered.** The switching game was played off a list of coordinates —
|
||
`move to (0, -2)` as a button, nothing on the board, and no account at all of the squares missing
|
||
from the list. `exploreMoves` returns the walk's REJECTIONS alongside its destinations, out of the
|
||
same traversal, so a reason on screen is the rule that actually refused the square rather than a
|
||
second guess at it. Five conditions: the rails do not meet, another train is on the card, the
|
||
industry is locked by MEN AT WORK (§9.3 — no train may enter it *or cross it*), the coupling would
|
||
overfill the consist, or it is a turnout you may run through but not stop on.
|
||
|
||
The board now shows where the crew is, where it may go, and marks the rest with its reason on the
|
||
card's own tooltip. A turnout is drawn differently from an obstruction, because it is not one — and
|
||
it is kept out of the Blocked panel, where every turnout in the district would otherwise appear.
|
||
|
||
**Mandatory coupling is named on the button.** "Move to (0, -2)" becomes "move to (0, -2) — couples
|
||
loaded hopper, caboose on the way (onto the nose)". Coupling is compulsory (§A.4) and was announced
|
||
only in the history panel, which is the one place a player is not looking while switching. 70 move
|
||
buttons in five games now say what they will pick up.
|
||
|
||
**The workers are on the card.** "How do I see the number of laborers in an industry card?" — you
|
||
could not; the number that decides every Cargo phase was in a side panel. Porters were worse:
|
||
`porters` had been on the view-model all along and **no renderer had ever drawn it**, which is why a
|
||
Restaurant's +1 porter had "no indication anywhere". Both are on the card now as free-over-total, and
|
||
the Facilities panel shows porters beside laborers.
|
||
|
||
**Ships-out and receives, in words.** A Refinery and a Grocer's Warehouse were distinguished by the
|
||
stroke colour of one 13×12px box and the direction of a 10px chevron. Rendered both and diffed the
|
||
SVG to check the complaint: identical shape, identical sign, same box count in the same place. The
|
||
card says `SHIPS OUT` or `RECEIVES` now.
|
||
|
||
**And the Office told the truth about itself.** `baseOf` returned zeros for a passenger facility
|
||
despite a comment claiming it read the Office tier, so a plain Depot displayed `out 1 +1` — crediting
|
||
a Modifier that was never played. The card tooltip had the mirror-image bug, reading the printed tier
|
||
rather than the Office as it stands, so a Modifier's effect never showed there either.
|
||
|
||
### Undo, and the chrome around it
|
||
|
||
**Undo, as far back as you like.** The save is the seed plus the intents, so undo is a replay without
|
||
the last one: no undo stack, no inverse of each action, and no way to reach a position the rules could
|
||
not have produced. Solitaire only. It is a deliberate take-back rather than a rewind — the RNG
|
||
advances with the replay, so a train card re-rolls the same Stage, but you can see that roll and then
|
||
spend the turn differently. `TODO.md` carries the question of whether a Stage boundary should become
|
||
a commit point.
|
||
|
||
**Both Division end labels were clipped.** They hung off the outside of the end cells and needed
|
||
padding wider than the caption to survive, which the board did not have — and giving it to them would
|
||
have spent that width on two captions instead of on the map. Centred under their own Division Point
|
||
they cost nothing, and `PAD` drops from 92 to 22.
|
||
|
||
**The turn chart scrolls away no more.** The title bar was sticky and the row under it — Day, Stage,
|
||
phase, waiting-on — was not, so scrolling the board took away the thing most worth glancing at. They
|
||
are one sticky block now.
|
||
|
||
**The timetable has a key.** Blue is a train due, violet is the Stage you are in, dimmed is gone, and
|
||
the green is a *flash* marking the slot the die just filled — which is exactly why it needed saying.
|
||
|
||
**And the rule that shapes every district is finally written down.** A district only grows outwards:
|
||
the Limits signs are the growth point on the Running Track and move outward with the card, and no
|
||
card anywhere can be inserted between two cards already down. Enforced since the beginning, stated
|
||
nowhere. It now sits beside the district, and a legal square that already carries a card says what
|
||
playing there would do — `EXTEND THE RUNNING TRACK HERE`, `ATTACH TO THIS CARD` — instead of lighting
|
||
up blue and saying nothing.
|
||
|
||
### Measurements
|
||
|
||
The two engine fixes take things away, so both were measured rather than assumed: 200 paired games
|
||
before and after come to **revenue 1.4 either way**, cards played 24.4 → 24.2. Neither was paying for
|
||
anything the bot relied on.
|
||
|
||
Worth stating plainly, since the changelog above still carries older numbers: the developer bot now
|
||
averages **1.4 revenue against a target of 20**, with 1 win in 200. That is not a regression from this
|
||
work — it is the same at `f4c0f49` — and it is consistent with what `TODO.md` already records about
|
||
barring curves from the Running Track and making industries stub-only. The bot has not been retaught
|
||
since those rules tightened.
|
||
|
||
Tests 386 → 402.
|
||
|
||
### A second playtest pass
|
||
|
||
**The arrival message told you to do work you could not do.** Train 4 is the *Express*, which carries
|
||
`expedite: true` — Q3 departs an expedited train in the SAME Stage it arrives. So it correctly
|
||
arrived and highballed in one Mainline Phase and correctly did not wait at the Depot. But
|
||
`trainArrived` narrated *"it will highball again next Mainline Phase, so any work must happen now"*
|
||
to every arrival, which for an expedited train is exactly backwards, and the log then contradicted
|
||
itself two lines later. The event carries `expedited` now and the message says which case it is.
|
||
|
||
**`TX14 (2)` was Stages remaining to cross that Mainline card**, not the car count — a bare figure
|
||
beside a train's name, read as the consist twice by the same player because that is the obvious
|
||
guess. Now `TX14 · 2⧗`.
|
||
|
||
**"stock a coach at (0, 0)"** reads as putting a car on the track, and was reported as exactly that
|
||
confusion: no train at the Depot, so how is a coach being stocked there? It is a load taken from the
|
||
Division Yard into the green Loading box, waiting for a train that can carry it — for a passenger
|
||
facility, people on the platform. It now says so.
|
||
|
||
**"trains enter" / "trains leave"** claimed the Division ran one way. Odd trains run west and even
|
||
run east, so both Division Points are a way on AND a way off; the buffer stops only mean it is a line
|
||
rather than a loop. Both ends now read `in and out`.
|
||
|
||
**The discard targets were "a very tiny highlight".** Choosing a card to PLAY lights big ghost squares
|
||
on the board; choosing one to discard lit a 2px outline on a small tile in a panel you might not be
|
||
looking at. The piles now grow, brighten, say DISCARD HERE, pulse once, and dim the Salvage Yard
|
||
beside them so the three that are choices read as a choice.
|
||
|
||
### The timetable, and watching the die land
|
||
|
||
Playing a train card rolls 1D12 for the Stage it departs, and the card simply left the hand — the
|
||
answer arrived as one line in the history panel, among many. The twelve slots have been in the Frame
|
||
all along, and only the standalone replay ever drew them.
|
||
|
||
The play page now has a **Timetable panel**: twelve Stages across, the train due out at each, the
|
||
current Stage lit, Stages already gone dimmed. Playing a train card **flashes the slot the die just
|
||
filled**, sounds a die-and-chime cue, and says it in words above the actions — *"Train 4 is scheduled
|
||
to depart at Stage 11 — see the Timetable"*. The flash marks the moment rather than the state: it is
|
||
gone by the next render, which is what makes it read as "that just happened".
|
||
|
||
No acknowledge-click. It would stop the game to say something the highlighted timetable says better,
|
||
and would be clicked through by the third time.
|
||
|
||
### The load pipeline is drawn the way freight actually flows
|
||
|
||
Reported after unloading at a warehouse: *"it went W, A, M, and then to the red box at the far right."*
|
||
|
||
**The engine was right.** §9.3 is explicit — *"The first Laborer replaces the load with an empty car
|
||
of that type on the industry's track and places the load on WORK. Additional Laborers move it to AT
|
||
then MEN. The last one places the load on a red Unloading box."* An unload ends in red, and it did.
|
||
|
||
**The drawing was wrong.** The pipeline was laid out `green → MEN|AT|WORK → red`, left to right, with
|
||
both arrows pointing right. But loading runs Green → MEN → AT → WORK → **car**, and unloading runs the
|
||
other way entirely: **car** → WORK → AT → MEN → red. Green and red therefore both belong beside MEN,
|
||
and the car belongs beside WORK — so putting red at the far right put it exactly where the car is,
|
||
and an unload appeared to run backwards across the whole row and land on the end it had just left.
|
||
|
||
Now green and red both sit before the sign with their arrows pointing opposite ways, and **only the
|
||
boxes an industry actually uses are drawn**: a Grocer's Warehouse has no green box and a Mine Tipple
|
||
no red one, where before every industry showed both and half of them were dead squares.
|
||
|
||
The Facilities panel had the same left-to-right assumption in its row labels. `green` and `red` are
|
||
now `waiting to load` and `cleared inbound`, each with the direction its loads travel spelled out,
|
||
and a row an industry cannot use is omitted rather than shown empty.
|
||
|
||
### A playtest report, worked through
|
||
|
||
**Backing up turned the train around.** `facing` is which way the ENGINE points, and it was reset to
|
||
the direction of travel on every move — so one reverse move silently spun the train about, everything
|
||
read "forward" again, and a run-around became pointless: you could change ends for free by backing up
|
||
twice. Running forward the engine leads and points the way it went; backing up it trails, still
|
||
pointing the way it came, which is the port it arrived through. Both hold around a curve.
|
||
|
||
**"drop 1 car(s)" hid an option entirely.** It never said which car, and it read identically for a
|
||
nose drop and a tail drop — so the action list's duplicate-label filter discarded one outright, and
|
||
setting out from the front of the train could not be chosen at all. The same failure as the turnout
|
||
rotation earlier, in a feature added two commits ago to fix the make-up deadlock. Now: *"set out the
|
||
caboose off the back"*.
|
||
|
||
**ABS Signals offered the Office Area.** The card says "any Mainline card", and
|
||
`checkEnhancementPlacement` reads `placement.col` as a Division NODE index for it — while the
|
||
candidate list handed it occupied grid cells. So "(0, 1)" was accepted because node 1 happened to be
|
||
a Mainline card: the label and the meaning were different things. It now offers the Mainline cards by
|
||
name, *"on the Uncontrolled Siding, out on the Mainline"*.
|
||
|
||
**Two T10 chips on one Office.** A train standing at the Office is on the Office grid card AND on an
|
||
A/D track, so it arrived in both lists and was drawn twice.
|
||
|
||
**The train was invisible on the board**, which is what made the switching game unplayable: every
|
||
decision is about car ORDER — which car comes off next, which end a cut couples onto — and the card
|
||
showed a name badge. The train is now drawn as it sits in the tray: engine in its place with an arrow
|
||
for which way it points, cars in order, loaded solid and empty hollow. That arrow is also what makes
|
||
"(reverse)" mean something.
|
||
|
||
**Modifier effects were applied and invisible.** The Ice House and Local Small Groceries both worked
|
||
— `capacity.outbound` and `laborers` went up — but the card draws `Math.max(1, greenCap)` green
|
||
boxes, so 0 → 1 looked identical, and laborers were never drawn at all. The Facilities panel now
|
||
shows laborers, outbound and inbound as numbers with what a Modifier added (`2 +1`), names the
|
||
Modifier cards beside the industry, and says in the tooltip what the card itself prints. Laborers had
|
||
been in a hover tooltip only, which is the wrong place for the number that decides every Cargo phase.
|
||
|
||
**Coupling was silent and left no trace.** A car simply vanished from the board with only a line of
|
||
history to say where it went. There are now two new sounds — a knuckle-coupler clank for coupling and
|
||
a quieter one for setting out — alongside the whistle, bell and conductor.
|
||
|
||
**Moves left were reported only in the history panel**, which is the one place a player is not
|
||
looking while switching. Now beside the buttons, and struck red at zero.
|
||
|
||
**A/D tracks were a tooltip.** "3 A/D tracks" with nothing on the card — the number that decides
|
||
whether the next arrival is an automatic collision. Drawn as pips, filled for taken; there is no room
|
||
on the card for more rails and the count is what matters.
|
||
|
||
**The phase changed under you.** Local Operations ends the moment the last Move is spent and the
|
||
automatic phases then run themselves, so the page could change between two clicks with no notice. A
|
||
banner now names the phase it moved to.
|
||
|
||
#### What was already right, from the same report
|
||
|
||
The car type and colour on the board (read as "cab in red" without being told), the Blocked panel
|
||
explaining a full industry track, the Facilities panel's accepted car types and spotted cars, and the
|
||
Division chip's `T10 (2)` with its consist on hover. All four were reported as useful, and none of
|
||
them changed.
|
||
|
||
### Softlock: a train being made up with no visible way to make it up
|
||
|
||
Reported at Stage 10 of seed 775569289 — Train 10 at the West Division Point, history saying "now
|
||
taking cars", and **nothing at all under Your Move**.
|
||
|
||
Moving train make-up onto the Division Yard chips took the "Making up …" group out of the action
|
||
list, and everything that was not a car went with it: the heading naming the train and what its card
|
||
calls for, and the "no more cars" button. The nine cars the train could take WERE clickable on the
|
||
yard chips the whole time — nothing on screen said so, and the panel a player looks at was empty.
|
||
|
||
The panel is back, and now says where to click: *"Click a car in the Division Yard below to add it —
|
||
9 kinds it may take are highlighted there."* It carries the pass button when passing is legal, which
|
||
§7 allows only when the Division Yard is bare — "must make every effort to find a suitable car".
|
||
|
||
**The engine was never at fault.** 2065 New Train decisions across 60 campaign games, and not one
|
||
offered zero options; the pass/place pair covers the phase. The panel still words the third case
|
||
honestly rather than implying a button that is not there.
|
||
|
||
**Two guards, because the obvious one would not have caught it.** A menu-level invariant — every
|
||
option the engine offers must be reachable through something the menu exposes — passes on this bug,
|
||
because `makeUp.pass` was in the menu and correct all along. What failed was the page never reading
|
||
it. So there is also a coarse check that `main.ts` references every field the menu offers: a field
|
||
nothing reads is either dead or a control that has gone missing. Verified by putting the regression
|
||
back and watching it fail.
|
||
|
||
### Four reports from playing seed 775569289
|
||
|
||
**A curve was described as a turnout.** Both read "east-west track with a 45° leg", which is a
|
||
turnout — a road straight across the card plus a leg off it. A curve has ONE road: in from the east
|
||
or west edge, along the centre line to the frog, out at 45° through the middle of a north or south
|
||
edge, and **nothing runs past it**. Which is precisely why it may not be laid in the Running Track,
|
||
so describing it as though it had a through track contradicted the rule that stops you.
|
||
|
||
**A curve in hand showed no preview.** The shapes were read off the card's legal PLACEMENTS, so a
|
||
card with nowhere legal to go had nothing to draw — and that is exactly when a player most wants to
|
||
see what the piece is. They now come from the card itself, which is where they belong: what a piece
|
||
looks like does not depend on whether there is currently a square for it.
|
||
|
||
**"Realignment on Mainline card 3"** named a raw node index. It said nothing about which stretch of
|
||
the Division it meant or what it would do, and it had no tooltip either — because the action list
|
||
attaches one only when a label happens to contain an em-dash, which this one did not. It now reads
|
||
|
||
> Realignment on the Uncontrolled Siding — the second Mainline card west to east; converts it to
|
||
> Double Track
|
||
|
||
and any action naming a card falls back to that card's own description when its label carries no
|
||
explanation of its own. That was the actual complaint: the same card explained itself perfectly in
|
||
hand and said nothing in the action list.
|
||
|
||
**It offered only one Mainline card, and that was correct.** `REALIGNMENTS` converts Plains, Curves,
|
||
Uncontrolled Siding and Trestle; the Division on that seed is a Heavy Grade and an Uncontrolled
|
||
Siding, so only the second could be converted. The engine was right and the label was hiding it —
|
||
"Mainline card 3" gave no way to tell a considered restriction from a bug. Naming the card fixes the
|
||
report without changing the rule.
|
||
|
||
### The hand is the action surface, and the yard makes up the train
|
||
|
||
The action list reached **22 buttons**, and most of it was a cross-product. A card appeared in two
|
||
panels under two different models: as a *subject* under "Play a card from my hand", which then
|
||
highlighted squares on the board, and as one flat button per Department under "Discard a card from
|
||
my hand". Four cards times three Departments was **twelve buttons repeating the same three choices
|
||
four times**, about 290px of the list. Separately, making up a train offered up to ten buttons
|
||
reading "add loaded hopper", "add empty boxcar" — while the Division Yard sat on screen already
|
||
showing exactly those cars by type and load state.
|
||
|
||
Both are now on the objects already being looked at, using the pattern board placement always had:
|
||
**pick the thing, then pick where it goes.**
|
||
|
||
- **Every card in hand carries its own verbs.** `play` highlights the squares it may go on, exactly
|
||
as before; a card needing no square (an Office upgrade, a train, a maneuver) goes down in one
|
||
click. `discard` lights up the three Department piles as targets — they already show their top
|
||
card and their depth, which is precisely what you choose between.
|
||
- **A make-up car is picked off the Division Yard chip** that shows it. The loaded and empty counts
|
||
are separate targets, because a car of a type and a load state is exactly what the choice is.
|
||
- The action list keeps what is not about a card or a car: the Local Operations choice, drawing,
|
||
switching moves, the Freight Agent, and finishing.
|
||
|
||
**Measured over a full game of seed 430: the widest action list went from 22 buttons to 5.** A test
|
||
now walks the same game and fails if it climbs back above 8.
|
||
|
||
The rotation step stays where it was and is now the only thing the placement panel shows — the card
|
||
is picked in the hand and the square on the board, so a rotation is the one question neither of those
|
||
can ask. Its hover previews, added earlier, are unchanged.
|
||
|
||
### A note on the two replay viewers
|
||
|
||
`TODO.md` now carries an item to decide between them. The standalone `node src/sim/replay.ts` writes
|
||
a self-contained HTML file that nothing links to and that `.gitignore` excludes; the site reads JSON
|
||
saves from `public/replays/`. The standalone one carries the bot's decision trace and a timetable
|
||
panel, which is debugging material rather than something a player wants. No action taken.
|
||
|
||
### A replay now looks like the game it is a replay of
|
||
|
||
**"Extra slow" was there and did nothing.** The site's replay viewer builds its interval with
|
||
whatever the speed select held when play started, and nothing re-read it — so changing pace
|
||
mid-replay had no effect at all and the pace looked stuck. The standalone replay had always
|
||
restarted its timer on change; this viewer was missed. Both offer the same five paces, extra slow
|
||
through very fast, and a test now asserts they stay in step.
|
||
|
||
**The turn chart lived on one screen out of three.** Where you are in the Day — the five phases with
|
||
the violet "you are here" — was in `main.ts` alone, so both replays reported the Day and the phase as
|
||
two plain strings. The same position looked like a different game depending on which screen you were
|
||
on. It is now `sim/turnchart.ts`, shared exactly as the board renderers are: the playable page and
|
||
the site viewer import it, and the standalone replay embeds it by `Function.toString()` because it
|
||
is a single file with an inline script and cannot import anything.
|
||
|
||
**And the side panels were three against eight.** The viewer showed the Division, the Office Area and
|
||
the log; the play page shows those plus cards in hand, the Department decks, the yards, the blockers
|
||
and the facilities. A replay could not answer *"why is nothing moving?"* — which is most of what a
|
||
replay is for. `web/panels.ts` now renders all of them for both pages, and the duplicated CSS is gone
|
||
from `play.html`.
|
||
|
||
Three tests hold it there: both screens must call the same panel renderers, all three must use the
|
||
shared turn chart and none may keep a private copy of the phase table, and the two viewers must offer
|
||
the same paces.
|
||
|
||
### You could not rotate a turnout at all, and now you can see what you are laying
|
||
|
||
**The rotation was being thrown away before it reached the menu.** `actionGroups` drops duplicate
|
||
labels, and `describeIntent` for a card play said only `play right-hand turnout at (0, 1)` — no
|
||
rotation in it. So a turnout's two orientations produced the same label and the second was silently
|
||
discarded. The "choose a rotation" step existed and worked; it was never given more than one
|
||
rotation to choose between. The label now names the orientation, and both survive.
|
||
|
||
**And the buttons are pictures now.** Hovering a rotation draws the piece as it will land on the
|
||
board, and hovering the card itself draws every shape it could be laid as — which is how a player
|
||
sees a turnout has two orientations before picking a square at all. Rendered by `officeSvg`, the
|
||
board's own renderer, on a one-card board: the preview and the board cannot disagree about what the
|
||
piece looks like, and the rails come from the engine's `connectionsFor`, so a preview cannot promise
|
||
a shape the placement will not produce.
|
||
|
||
`Placeable.spots` carries the links for this. `data-tip-html` on the tooltip renders a figure above
|
||
the caption; it is only ever set from markup this app builds.
|
||
|
||
### Turnouts say what they do, and a curve may not break the Running Track
|
||
|
||
**"Right-hand turnout, stem east, through west, diverges north at 45°"** is three pieces of jargon
|
||
and a compass reading, and none of it answers the only question being asked: *if my train comes in
|
||
from over there, where can it go?* Turnouts now read
|
||
|
||
> allows traffic from the east to travel west or turn to the south
|
||
|
||
and curves
|
||
|
||
> carries traffic from the west round to the south
|
||
|
||
everywhere they appear — on the card in hand, on the placement it would make, and on the card once it
|
||
is down. §A.1's rule falls out of the same sentence rather than needing a shouty clause of its own:
|
||
a train coming the other way, from the through end or the diverging one, may only leave by the stem,
|
||
so the two roads never join.
|
||
|
||
**A curve laid in the Running Track dead-ends the main.** A curve has ONE road — from an east or west
|
||
edge round to its 45° leg — so a card of it standing in the running row stops the through route at
|
||
that square and cuts the Office off from its own Limits. Nothing forbade it, and the bot did it: an
|
||
early trace shows it laying a `ne` curve at (0,−1), turning the west end of its own Running Track
|
||
into a stub. Every card that may stand in that row now has to carry the road across it —
|
||
`carriesThroughTrack` — which straights, turnouts, Limits signs, the Office and industries all do.
|
||
Reported as `BREAKS_RUNNING_TRACK` rather than `NOT_CONNECTED`, because it is a different mistake:
|
||
the card would join perfectly well and would still leave the main stopping dead at it.
|
||
|
||
**The crossover is confirmed, both hands.** A turnout that *"allows traffic from the east to travel
|
||
west or turn to the south"* joins the one directly beneath it that *"allows traffic from the west to
|
||
travel east or turn to the north"* — the two 45° legs are one continuous rail across the card edge.
|
||
It already worked; it now has a test that says so in those words, along with its mirror built from
|
||
the other hand, the mismatched pair that must NOT join, and a crew actually running down through it
|
||
onto the parallel track.
|
||
|
||
**Measured, and worth flagging:** barring curves from the running row cost the bot a good deal —
|
||
districts 28.0 → 19.7 cards, facilities 2.23 → 1.85, revenue about 2.0 → 0.8. The rule is right and
|
||
the bot was partly living off an illegal placement. Two tests needed widening rather than weakening
|
||
as a result: the unload regression sampled three deals and now samples twelve (unloads still happen,
|
||
34 across a 40-deal sweep, just not on those three), and the canvas bounds test now holds a straight
|
||
as well as a curve, since a curve alone is offered only one square once the running row is closed to
|
||
it.
|
||
|
||
### The Crew Tray is a train, and a train must be made up to leave
|
||
|
||
**§8.2 was not checked at all.** A train could highball onto the Mainline engine-last with its
|
||
caboose in the middle. Now a train held at the Office is held until it is made up: the engine at an
|
||
end of the tray — pulling or pushing, both are real — and the caboose at the far end from it. The
|
||
check is deliberately direction-free, because what a train may not be is *broken-backed*, with the
|
||
engine buried among its own cars and some ahead of it and some behind. That state is only reachable
|
||
through switching: a train arrives made up and comes apart because the player took a cut onto the
|
||
nose or picked cars up in a run-around.
|
||
|
||
**`engineAt` was written and never maintained.** Cars taken onto the nose go AHEAD of the engine —
|
||
Appendix A: *"a train can pick up two cars and add them to the Crew Tray in order that they were in,
|
||
pushing them into the Facility"* — so the engine stops leading, and its recorded index did not
|
||
follow. It does now, and drops adjust it the other way.
|
||
|
||
**Setting out from the nose was missing entirely.** Appendix A uses the move in its own worked
|
||
example — *"Back up and drop off everything on the nose of your train (red and blue) on Card B"* —
|
||
and `switch.dropCars` could only ever take from the tail. Without it, cars taken onto the nose could
|
||
never come off, so an engine buried in its own train had **no way back to an end**: the first version
|
||
of the make-up rule stranded a train permanently in 6 games of 40, holding an A/D track for the rest
|
||
of the game. `fromNose` fixes it, and a cut is now guarded to come off an OUTER end only — lifting
|
||
cars from beside the engine would leave the far end of the train coupled to nothing.
|
||
|
||
The bot learned both remedies: dig the engine out when it is buried, and shed a misplaced caboose
|
||
when it is at a reachable end. Measured over 40 games: **106 make-up holds across 6 games → 13 across
|
||
1**, revenue 1.55 → 2.05. The one that remains is a caboose stuck mid-train, which genuinely needs a
|
||
run-around or a Small Yard — the game working, not a defect.
|
||
|
||
**A knock-on worth recording:** with cars now set out properly, the freight pipeline runs clean — 55
|
||
loads started and 54 completed across the 40-game sweep, with **zero jams of either kind**. The
|
||
regression test that asserted the bot clears a MEN|AT|WORK jam had become untestable, so §6.3's
|
||
unjam is now asserted directly against a constructed jam instead of hoping the bot stumbles into one.
|
||
It also checks that clearing the jam reopens the track, which is the point of it.
|
||
|
||
**You can see which car is where.** The board printed the first three characters of each car's
|
||
label, which for "loaded hopper" and "loaded boxcar" alike is `loa` — every car on the map looked
|
||
identical. The switching game is entirely about getting the RIGHT car to the right industry, so type
|
||
now reads by colour and by three letters (`box` `hop` `tnk` `rfr` `cch` `cab`), and loaded shows as a
|
||
filled slot against an empty one's outline — the same distinction the printed game makes with
|
||
coloured tokens.
|
||
|
||
### Operational Rail — a locked industry could be driven straight over
|
||
|
||
Checked the whole rule against Appendix A of `StationMasterPrototypeRules.pdf`, which defines it
|
||
outright: *"Operational Rail is any track card that a train can stop and leave Rolling Stock
|
||
(uncouple) on. Operational Rail is any track card with a train wheel icon on it."*
|
||
|
||
Most of it was already right, and the wheel icons in the rules diagrams confirm which cards carry
|
||
one. Straights, curves, industries and the Limits cards all do. The turnout does not, and the page
|
||
says so in words — *"Since there is no Operational Rail wheel icon, the train may not stop on this
|
||
card"* — so a train runs through one and may not stop or uncouple on it. Both already held.
|
||
|
||
**The Office is Operational Rail.** The Depot card in the diagrams carries a wheel icon and is drawn
|
||
as one of the green squares a Crew Tray may move to, and the Special Rules say *"While your Office
|
||
Track is considered Operational Rail, Rolling Stock may not be dropped off here."* So a train may
|
||
stop there and may not leave cars — which is what `canDropCarsAt` already did. Worth stating plainly
|
||
because it is easy to read the "no cars here" half as "not Operational Rail", and the A/D track
|
||
mechanic depends on trains being able to hold at the Office.
|
||
|
||
**The real gap was §9.3's lockout.** *"While ANY loads are in the MEN | AT | WORK track, the
|
||
industry's track is locked down... It loses its status as Operational Rail. No cars can be picked up
|
||
or dropped off, and no trains may occupy **or move on** it."* Losing Operational Rail status only
|
||
stops a train FINISHING somewhere — a turnout is not Operational Rail either and trains run through
|
||
one all day. Passage was never blocked, so a crew rolled straight over a locked industry and, because
|
||
coupling is automatic and mandatory, picked up the cars spotted on it on the way past. Those are
|
||
precisely the two things the safety lockout exists to prevent.
|
||
|
||
`isLockedByWork` is now separate from `isOperationalRail` for that reason: "cannot stop here" and
|
||
"cannot pass through here" are different properties and only a locked industry has both.
|
||
|
||
Covered by a new suite in `track.test.ts` that walks each card type against the wheel-icon rule,
|
||
asserts the Office may be stopped at but not unloaded on, asserts a turnout may be passed but not
|
||
stopped on, and asserts a locked industry blocks both stopping and passage — then reopens when the
|
||
work clears. It also checks the supply catalogue's `isOperationalRail` column against the rule, so
|
||
the data and the diagrams cannot drift apart.
|
||
|
||
### Industry cards: on a stub, one of a kind, and never both ends of a chain
|
||
|
||
From the sheet, the "Placed" column reading identically for all six industries — **"Straight, Stub
|
||
(not on Running Track)"** — and the "Lockouts" column beside it.
|
||
|
||
**An industry may no longer be built on the Running Track.** It was allowed and merely *warned*
|
||
about: the card text noted that a car left standing there would be hit by the next arrival. That is
|
||
a hazard, not a rule, and it let a player skip the district entirely and spot cars on the main line —
|
||
which removes the whole switching puzzle, since the point of a siding is getting a car down off the
|
||
main and back. `check` now returns `ON_RUNNING_TRACK`.
|
||
|
||
**No two of the same industry in one Office Area.** The sheet states this in the Freight House row,
|
||
which lists Freight House among its own lockouts; the catalogue had dropped that self-reference as if
|
||
it were a typo. It is a general rule, so it is enforced for every kind in `isLockedOut` rather than
|
||
repeated in all six entries.
|
||
|
||
**Producer and consumer of the same commodity stay apart.** Mine Tipple makes the coal a Power Plant
|
||
burns; the Refinery makes the oil it also burns; Packing Sheds fill the reefers a Grocer's Warehouse
|
||
empties. Build one end of a chain or the other, never both — which is what pushes freight to run
|
||
between districts instead of circling inside one. The pairs were already right; they had **no test
|
||
coverage at all**, and now have a suite that checks each pair in both directions, checks that
|
||
industries sharing no commodity may stand together, and checks the catalogue against the sheet
|
||
column so a change to it has to be deliberate.
|
||
|
||
**Measured, 60 solitaire Standard games:** 0 industries on the Running Track, 0 duplicates. Industry
|
||
placements fell from 3.84 a game to 2.23 and revenue from 2.87 to 1.35, because the bot builds
|
||
shallow districts and there are now far fewer legal squares. Not rebalanced — deliberately. The
|
||
counts, the industries and the track mix are all due a pass together once the rules are right.
|
||
|
||
One consequence worth naming: `flyingSwitch` stopped firing in the 60-game reachability sweep. The
|
||
rule is fine — `mainline-cards.test.ts` exercises it end to end on a hand-built siding — but the bot
|
||
no longer gets a crew next to an industry. It is exempted **by name** in that test, with the reason
|
||
written next to it, so the other forty-odd event checks stay live and deleting the line is what
|
||
proves the bot has been fixed.
|
||
|
||
### §6.2's reshuffle, and the Departments and Salvage Yard on screen
|
||
|
||
**The reshuffle existed as a fiction.** `events.ts` declared `{ type: 'deckReshuffled' }` and
|
||
`narrate.ts` had a line of prose ready for it — "Home Office deck ran out — Salvage Yard reshuffled
|
||
back in" — and nothing anywhere emitted or reduced it. `check` just returned `DECK_EMPTY`. A declared
|
||
event with narration written for it reads as an implemented feature to anyone grepping for one, which
|
||
is worse than an obvious gap.
|
||
|
||
Now real: when a draw takes the last card, the Salvage Yard and all three Department decks are
|
||
collected, reshuffled, and §4.6-4.7's opening is re-run — three cards turned face up as the
|
||
Departments, the rest face down as the deck. Cards played onto the board are **not** recovered; they
|
||
are on the table, which is where they belong. A game that has genuinely used everything still ends on
|
||
`DECK_EMPTY` rather than reshuffling an empty sweep.
|
||
|
||
The full shuffled order rides the event rather than being recomputed from `rngState`. A save is a
|
||
seed plus the intents, so events are never serialised and the size costs nothing — and an event that
|
||
states the outcome outright cannot drift from the reducer the way a re-derivation can. Covered by a
|
||
test that builds the same position twice and asserts the two decks come out identical.
|
||
|
||
**It has not fired in play yet, which is worth knowing.** Solitaire Short/Standard/Campaign end with
|
||
186.8 / 176.2 / 168.8 cards left of 243 and never ran dry across 60 games each; four-player Campaign
|
||
ends with 86.6 across 25 games. It is a safety net, not a live mechanic. It is not the case that the Departments
|
||
only grow — a player takes the top card of one as their draw as readily as discarding onto it, so a
|
||
Department can be drawn down and refilled from the Home Office deck. What the reshuffle guards is the
|
||
Home Office deck itself running out, which is possible whichever way the piles happen to be moving.
|
||
|
||
**The Salvage Yard is a pile like the others**, and now shown like them. It was already modelled as a
|
||
list; what it lacked was any presence on screen, which mattered the moment it became the thing that
|
||
comes back in a reshuffle. Watching it fill is the only warning a player gets that the deck is about
|
||
to turn over.
|
||
|
||
**All four piles now show their top card and their depth.** Each is drawn as a card with the pile's
|
||
name and a count badge on it, then the face-up card underneath. The depth is a count and not a hint:
|
||
only the top card may ever be drawn, so everything below it is out of reach, and choosing where to
|
||
discard is choosing what to put there.
|
||
|
||
### The Departments are decks, and you choose which one to discard onto
|
||
|
||
From the designer: *"when discarding from their hand, the player can select which department card
|
||
deck they want to place the discard on top of. Department card decks are shared across all players.
|
||
When pulling from a department card deck players may only pull the top card."*
|
||
|
||
The rules already said so and the code had read them the other way. §6.2: a discard is placed "face
|
||
up **on top of** one of the three Department slots", and a draw takes "the **top** face-up card".
|
||
Both phrases only mean something over a pile. Gap 4a had concluded the Departments were "three
|
||
face-up market slots fed from the one deck, not decks with their own contents"; that finding is now
|
||
marked corrected in `open-questions.md`.
|
||
|
||
**It was destroying cards.** `cardDiscarded` did `departments[toSlot] = cardId` — assignment, not a
|
||
push — so discarding onto an occupied Department annihilated the card already face up there. A closed
|
||
deck was quietly leaking. It survived because the only card-conservation test ran at setup and never
|
||
again; there is now one that counts after a full game, and one that asserts no id is ever in two
|
||
places at once.
|
||
|
||
**And the choice was invisible.** All three discards described themselves as `discard X`, and the
|
||
action list drops duplicate labels — so three genuinely different decisions collapsed into a single
|
||
button and the Department could not be picked at all. Discards now read `discard Freight House onto
|
||
Department 2, burying Brakeman`, and a draw reads `take Depot from Department 1, 3 buried beneath
|
||
it`. The browser shows each pile's top card with a `+n under` count, because a deep pile is where
|
||
cards have been put beyond reach and that is what a discarding player is choosing between.
|
||
|
||
**Refill timing changed with it.** §6.2 refills an *empty* Department from the Home Office deck. The
|
||
old code refilled after **every** Department draw, which was harmless when a slot held one card and
|
||
would now drain the deck into the piles. It refills only when taking the last card empties one.
|
||
|
||
**The bot got the strategy this opens up.** It used to take the first discard option, always
|
||
Department 1, burying whatever sat there — including the Depot it was waiting on. It now covers the
|
||
face-up card least worth keeping reachable, never one it would take, and breaks ties toward the
|
||
shallowest pile. Measured over 100 games: spreading discards **2.87** revenue, concentrating them on
|
||
the deepest pile 2.67, indifferent 2.67. Three piles offer three face-up cards, and piling onto one
|
||
of them leaves the other two showing whatever they started with.
|
||
|
||
In a competitive game the same call reads the other way round — burying a card a rival wants is an
|
||
attack rather than housekeeping — which is exactly why the choice belongs to the discarding player.
|
||
|
||
**Measured, 100 solitaire Standard games:** Departments hold 8.3 cards between them at game end,
|
||
deepest single pile 17. The Home Office deck ended with 176.6 of 243 and **never ran dry**, which
|
||
matters because §6.2's reshuffle — collect the Salvage Yard and all three Departments, reshuffle,
|
||
re-establish the deck — **is not implemented**. It has never been reachable in a 5-Day solitaire
|
||
game; it will matter for Campaign length and for four players. *(Implemented in a later entry.)*
|
||
|
||
### Track is a deck card, not a private supply
|
||
|
||
Reported by the designer: *"all track cards are included in the home office deck and are played from
|
||
there like any other card."*
|
||
|
||
The error is visible in the spreadsheet. `docs/Deck cards2.xlsx` has a column B headed **"Number in
|
||
Deck"** — 32 straights, 16+16 curves, 4+4 sharp curves, 16+16 turnouts, **104 cards** — and a LAST
|
||
column headed **"Track Per Player"** reading 8/4/4/1/1/4/4 = 26. The code took the last column as a
|
||
separate physical stack and wrote *"Track is NOT in the Home Office deck — this is the single biggest
|
||
structural change from the placeholder."* It is 104 shared among four players, not a second pile. The
|
||
sheet's own totals settle it: "Sum other 115", "Total track 104", grand total 231 — and 115 + 104 +
|
||
12 start cards is exactly 231.
|
||
|
||
**What went.** `TRACK_SUPPLY`/`TRACK_PER_PLAYER`, `OfficeArea.trackSupply`, the `track.lay` intent,
|
||
the `trackLaid` event, `protoTrackCard`, `TurnState.laidThisTurn` (the one-piece-a-turn cap, which
|
||
only existed because a private supply had nothing else bounding it), and the "Your Track Supply"
|
||
panel. `CardKind` for track gained a `hand`, because handedness is the diagonal and a track card
|
||
without one cannot say what it may be joined to.
|
||
|
||
Most of the plumbing was already there and dead: `checkPlay` had a `track` branch, `protoCard` had a
|
||
`track` branch, and the `cardPlayed` reducer already placed a track card with a rotation. Track had
|
||
been a card once, and moving it back was largely deleting the parallel path.
|
||
|
||
**The deck is 243 cards, of which 104 are track** — the largest category by some way, and the point
|
||
of the change: building a district is now paid for in the industry or train you did not draw, and a
|
||
three-card hand is the real constraint on how fast a railroad grows.
|
||
|
||
**Measured over 100 solitaire Standard games, against the same run with track as a private supply:**
|
||
|
||
| | private supply | in the deck |
|
||
| --- | ---: | ---: |
|
||
| deck size | 139 | 243 |
|
||
| district size | 28.5 cards | 28.0 |
|
||
| mean max depth off the main | 1.45 rows | 1.94 |
|
||
| turnouts / curves / straights per game | 7.6 / 7.2 / 3.4 | 5.6 / 7.4 / 5.6 |
|
||
| facilities placed | 4.56 | 3.84 |
|
||
| districts with a run-around | 70/100 | **29/100** |
|
||
| facilities on a run-around | 0.51/game | **0.18** |
|
||
| revenue | 5.80 mean | **3.05** |
|
||
|
||
**Revenue nearly halved, and that is the headline for the next decision, not a defect to paper
|
||
over.** A run-around needs a turnout, a matching curve, straights, a second curve and a second
|
||
turnout — all of the right hand, arriving in a three-card hand in a usable order. It used to be a
|
||
shopping list; it is now a draw. Two test floors were re-baselined against the measurement with the
|
||
old figure recorded beside them, deliberately set BELOW what was measured so they detect the loop
|
||
machinery breaking rather than endorsing 29%.
|
||
|
||
**Two bot fixes fell out of it, both real.** Track became an ordinary `card.play`, so the generic
|
||
"play anything placeable" fallback started dumping track on whatever square was legal — bypassing
|
||
`bestTrackLay`, which had already looked at the same piece and declined it. Measured: 26 of 60
|
||
districts ran the siding past the last column with a way up. A track card the scorer will not use is
|
||
a card to discard. And `arcsLeft`, which asked a supply that no longer exists, became `arcInHand`:
|
||
"have I got a curve of this hand?" is now a question about the hand, not a certainty.
|
||
|
||
**Still open, and now urgent.** The office counts are doubled (Depot 4→8, Station 2→4, Terminal 1→2,
|
||
Q12) and the industries tripled (Gap 12), both tuned by measuring a deck with **no track in it** —
|
||
25 of 100 games never drew a Depot and never escaped Whistle Post. Adding 104 cards dilutes every
|
||
draw by 43%, which is exactly what those multipliers were compensating for, so they are now either
|
||
badly needed or badly wrong and only a measurement will say which. `test/setup.test.ts` records both
|
||
departures and flags them for re-measurement.
|
||
|
||
### The placement labels described the mirror of the card they would lay
|
||
|
||
Reported as "I can't play a turnout north or south of an existing turnout". It was always legal — a
|
||
turnout under a turnout is a **crossover**, and it is how a siding gets a track running parallel to
|
||
the Running Track. The engine accepts left-over-left and right-over-right today, and the square was
|
||
offered and clickable. What was wrong was the words on it.
|
||
|
||
`rotationNote` in `src/web/game.ts` called `variantsFor(geometry)` **without the hand**. Hand is the
|
||
diagonal, so without it `variantsFor` answers for the left-hand card whatever you are holding. Every
|
||
right-hand turnout was offered as *"stem west, through east, diverges south"* — the exact mirror of
|
||
the card it would lay — and every right-hand curve named the wrong edge. The placement was always
|
||
correct and only the description lied, which is the kind of bug that survives a green test suite and
|
||
makes a working feature feel broken.
|
||
|
||
Fixed by threading `hand` through `rotationNote` → `variantLabel`, and covered by a test that walks
|
||
every geometry × hand × rotation and checks the label against `variantsFor`'s own answer.
|
||
|
||
**And a placement now says what it would connect to.** Two cards meeting at an edge is not a rail —
|
||
on a north or south edge their 45° legs must also share a diagonal — so "is this square legal" and
|
||
"does this piece meet the one I am aiming at" are different questions and only the first was on
|
||
screen. Spots now read `(-1, 1) — stem east, through west, diverges north at 45° · joins the track
|
||
above`, which is the crossover named outright.
|
||
|
||
### A New game button, instead of finishing the one you have
|
||
|
||
`start()` restores from localStorage on every load and the only "new game" button lived on the
|
||
game-over screen, so a game you no longer wanted followed you across reloads with no way out.
|
||
|
||
`New game` sits beside `Save replay` in the header. It confirms once past the opening Stage — the
|
||
save *is* the game, there is no undo, and the replay download is right there — then clears the save
|
||
and reloads. It drops any `?seed=` from the URL as well: leaving it would deal the same game again
|
||
and look like the button had done nothing.
|
||
|
||
### The track is 45° geometry, drawn from the printed cards
|
||
|
||
The prototype designer's feedback: **there are no north–south tracks.** Straight runs are always
|
||
east–west, the east–west line sits dead centre on the card rather than biased to the top, and a
|
||
turnout is an east–west through track plus a curved leg meeting the north or south edge at 45°, which
|
||
must line up with the corresponding leg on the card it abuts.
|
||
|
||
Measured off `docs/tracks.png` rather than inferred. Card grid lines at y = 15/164/314/464/614/764/
|
||
914/1064/1213 on a 2136×1397 sheet; rail centres at 89.5/236/–/–/689.5/839/989/1135.5/1288.5 — the
|
||
card's exact vertical middle, within a pixel, on every one of the nine rows. Cards are 259×150
|
||
(aspect ≈ 1.73). Every leg crosses the middle of its edge. Four shapes exist: turnout with the leg
|
||
off the west end, turnout with the leg off the east end, curve west↔south, curve east↔south, plus the
|
||
plain straight. Depot, Station, Terminal, Refinery, Freighthouse, Coal Tipple, Manufacturing and Town
|
||
are all plain east–west straights with **no diverging leg at all**.
|
||
|
||
**Slope is now part of adjacency.** Name the two diagonals after the pair of arcs that mate across a
|
||
horizontal card edge: `ne_sw` (port pairs {n,e} and {s,w}) and `nw_se` ({n,w} and {s,e}). An `sw` card
|
||
above an `ne` card is one unbroken rail; `sw` above `nw` is a V — both cards have the port, both legs
|
||
meet the same point on the edge, and they still do not connect. `joins(a, p, b)` in
|
||
`src/engine/track.ts` is the single adjacency test, and every `hasPort(nb, opposite(p))` in the
|
||
engine, the bot and the tests now goes through it. Leaving one behind reintroduces the V.
|
||
|
||
**Handedness is that diagonal.** A printed card has a back: it turns 180° but never flips. So a
|
||
straight has ONE orientation, and a curve or turnout has two — 0° sends the 45° leg south, 180° sends
|
||
it north, and neither changes diagonal. Left-hand reaches `sw`/`ne`, right-hand `se`/`nw`. That is
|
||
what makes the 4-left/4-right split of the curve and turnout supply mean something: a run-around needs
|
||
one card of each hand — a left turnout down, its matching left curve, straights along, a right curve
|
||
back up into a right turnout. `variantsFor` takes a `hand` argument for this reason.
|
||
|
||
**What went, and what opened.** `TrackAxis` and every `axis` field are deleted outright — there is
|
||
one axis now, and a one-valued field invites the old branching back; deleting it turned `tsc` into
|
||
the migration checklist. The Office's `e-s`/`w-s` stubs are gone, as are north–south facility
|
||
placements. Q7's "a district hangs below the Running Track" is lifted: a turnout turned 180° reaches
|
||
north, so `placementCandidates` and `adjacentFacilityCoord` no longer reject the rows above, and §9's
|
||
"nine nearby spots" really is nine. `legalIntents` offered six variants per placement per card; the
|
||
widest set is now two.
|
||
|
||
**The renderer draws the card rather than approximating it.** `RAIL` moves from 30 to `H/2`, and the
|
||
card widens from 132×96 to 166×96 to match the sheet's 1.73 — the aspect is not decoration, since the
|
||
frog where a 45° leg meets the through track sits `H/2` from the centre and a squarer card pushes it
|
||
almost to the edge. The fixed elbow at `(W/2, RAIL + (H-RAIL)/2)` is replaced by the real frog at
|
||
`(W/2 ± H/2, H/2)` and a segment at exactly 45° to the edge midpoint. That elbow sat *below* the rail
|
||
on the assumption everything diverged downward, so a leg reaching north was drawn as a hook that
|
||
dropped past the rail and came back up. Four tests in `test/web.test.ts` now measure the emitted SVG:
|
||
the through rail level and centred and spanning the full card, every leg at 45°, `ne`/`sw` on one
|
||
diagonal and `nw`/`se` on the other, and the leg crossing the edge at its midpoint. The enhancement
|
||
label moved above the rail, where it used to print along it.
|
||
|
||
**Measured, 100 solitaire Standard games, against the same run before the change:**
|
||
|
||
| | before | after |
|
||
| --- | ---: | ---: |
|
||
| mismatched 45° joints on finished boards | — | **0** |
|
||
| district size | 25.4 cards | 28.5 |
|
||
| mean max depth off the main | 2.38 rows | 1.45 |
|
||
| built above the Running Track | 0/100 games | 93/100 |
|
||
| straights laid | 1.01/game | 3.44 |
|
||
| districts with a run-around | 99/100 | 70/100 |
|
||
| facilities on a run-around | 0.82/game | 0.51 |
|
||
| revenue | 7.34 mean | 5.80 |
|
||
|
||
The shallower districts are intrinsic: no card joins north to south, so descending a row costs at
|
||
least two cards — the leg out, then a curve turning the run back east–west. Districts are now wide
|
||
and shallow, which is what the printed sheet and a real yard both look like, and the run along the
|
||
siding is straights, which is why the bot lays three times as many (13 of the 18 enhancement cards
|
||
need one).
|
||
|
||
**The revenue and run-around drops are honest, not a regression to chase.** A run-around costs more
|
||
pieces than it did, and the Office no longer offers a free way back up onto the main — it used to
|
||
carry `e-s`/`w-s` stubs, so every district had one guaranteed climbing point. Two bot heuristics were
|
||
tried against it and both rejected on measurement: preferring one side of the main changed nothing at
|
||
−4 and made things worse at −100 (62/100 run-arounds), and paying more for the second turnout that
|
||
closes a siding bought 86/100 run-arounds and 1.08 facilities on a loop but cost 9% of revenue
|
||
(5.80 → 5.25). Revenue is the game's own measure, and both structural floors in `test/sim.test.ts`
|
||
still pass, so neither shipped.
|
||
|
||
The bot is otherwise rewritten around the new geometry: `descendFrom`/`waysOff`/`reachableOffMain`/
|
||
`runAroundCells` take a side rather than assuming "below", the arc score asks `joins` instead of
|
||
reading the arc name, and `arcsLeft` is asked per hand — a left-hand turnout's leg can only be
|
||
continued by a left-hand curve, and a supply full of right-hand ones is no help to it.
|
||
|
||
### Q13 — a train that catches the one ahead runs into it
|
||
|
||
Answered, and implemented as option B: **collide on catching up**, which is the version that rewards
|
||
judging the gap.
|
||
|
||
§10 makes a Mainline collision the Superintendent's fault and removes both trains, and ABS Signals
|
||
exists to stop trains rear-ending each other — but §8.3's trigger list never named one and nothing
|
||
was implemented, so granting clearance was FREE: both trains survived, no penalty, and ABS Signals
|
||
protected against nothing. Now a following train that closes on the one ahead runs into it, and one
|
||
that never closes is fine, so clearance is a bet on relative speed rather than a formality. §2.1
|
||
divides the card into two regions, and sharing one is what "caught up" means. ABS Signals does what
|
||
it prints instead: the follower stops short and holds.
|
||
|
||
**Not on a card that prints "trains may pass".** The first version fired 0.41 times a game while the
|
||
bot never once granted clearance, which is the tell — those were all Double Track and Uncontrolled
|
||
Siding, cards that hold two trains because they HAVE two roads. Catching up there means going past,
|
||
which is what the card is for.
|
||
|
||
With that corrected the mechanic is invisible to the current bot, because it always denies
|
||
clearance. That is the right shape, and the teeth are real:
|
||
|
||
| Superintendent | revenue | rear-enders | ABS holds |
|
||
| --- | --- | --- | --- |
|
||
| always denies (the bot) | 7.34 | 0.00/game | 0 |
|
||
| **always allows** | **−5.13** | **2.20/game** | 19 |
|
||
|
||
So the bot's always-deny policy — a deliberate choice made when clearance was free and the
|
||
arithmetic only guessed at — turns out to be correct, and is now correct for a measured reason.
|
||
|
||
Tested deterministically rather than through the bot: two trains built onto one single-track card
|
||
with the follower closing, which collides whichever order the phase processes them in, and the same
|
||
pair again with ABS Signals to prove it holds instead.
|
||
|
||
### The engine has a place in the train, and the yards are visible
|
||
|
||
**The engine had a position the game recorded and never used.** `engineFront` was a boolean, written
|
||
in three places and read in none — so every consist was drawn as an anonymous row of cars. It is now
|
||
`engineAt`, an index into the consist, because a Crew Tray is an engine plus its Rolling Stock and
|
||
the engine may be PULLING (ahead of everything), PUSHING (behind everything), or in the middle doing
|
||
both at once. A boolean cannot say the third thing. Consists are drawn with `ENG` where it sits.
|
||
|
||
The engine is deliberately NOT one of the `consist` entries: §8.2 counts the consist as Rolling
|
||
Stock, and the four-car limit (§A.4) is a limit on cars, not on the locomotive hauling them.
|
||
|
||
**Both yards are now on the page**, by car type and split loaded / empty, with the Division Yard
|
||
outlined the moment it goes bare. This matters more than it did: the Classification Yard returns to
|
||
service only when the Division Yard is empty, so the supply genuinely runs down, and a game that
|
||
never showed either yard gave no warning at all.
|
||
|
||
It shows the pressure immediately. In a finished game the Division Yard held 30 cars — hoppers,
|
||
tanks and cabooses — and **no boxcars, coaches or reefers at all**, while 19 of them sat in
|
||
Classification unable to come back, because the Division Yard was not bare.
|
||
|
||
Crew Tray scarcity was already implemented and is left alone: a train with no free tray is held
|
||
(`trainHeld`), which is §7.
|
||
|
||
### The Classification Yard rule, from the source — and my guess was worth 2.4 Revenue it should not have been
|
||
|
||
Answered: used Rolling Stock is set out in the Classification Yard, used engines and cabooses go
|
||
straight back to the Division Yard, and **the Classification Yard empties only when the Division
|
||
Yard is bare** — then all of it returns at once.
|
||
|
||
That is a much harder rule than the one I invented. Returning cars at every DAY boundary keeps the
|
||
yard topped up continuously; this lets it run down to nothing and refill in one go, which is the
|
||
whole of the supply pressure the game is meant to have. Measured paired over 400 seeds:
|
||
|
||
my Day-boundary guess 9.67 the real rule 7.25
|
||
paired change -2.42 ± 0.49 (t -9.67) 235 of 400 seeds affected
|
||
|
||
So the +2.32 celebrated when the Classification Yard was first made readable was very largely an
|
||
artefact of getting the trigger wrong. The refill is now checked wherever a car leaves the Division
|
||
Yard, so it fires the moment the yard empties rather than at the next convenient tick.
|
||
|
||
**Poling is dealt zero copies** rather than deleted. Its effect is "TBD in the source", so there is
|
||
nothing to implement and a card that cannot be played is worse in a hand than absent from the deck.
|
||
The catalogue entry stays so the gap remains visible. Deck 140 → 139, solitaire 118 → 117.
|
||
|
||
**Heavy Grade orientation stays rolled from the seed**, and is now documented as temporary in the
|
||
code rather than only in TODO: the card says the player sets it, but it is dealt during setup and
|
||
setup has no decision point — `createGame` is a pure function of the seed, which is also what makes
|
||
a save portable.
|
||
|
||
**A counting question this raised, and could not answer.** A census of every holder of rolling stock
|
||
comes to 92 against the 80 dealt. That is not proof of duplication: `outboundBox`, `inboundBox` and
|
||
`menAtWork` all hold `RollingStock`, and stocking a green box takes a LOADED CAR out of the Division
|
||
Yard — so some of those objects are cargo in transit rather than cars, and nothing distinguishes
|
||
them. An accounting test was written and then withdrawn, because it could not tell the two apart.
|
||
Logged: until a load is its own type, "is any stock being created or destroyed?" is unanswerable.
|
||
|
||
### The freight figures were counting one half of freight
|
||
|
||
A measurement fix, not a game fix — but it is the instrument every balance decision is read from.
|
||
|
||
`rev.freightUnload` was assigned `eventCounts['unloadBegan']`: unloads STARTED, not Revenue EARNED,
|
||
and the two differ by every unload that never finished. `grossFreight` then used `freightLoad`
|
||
alone, so `freightShare` omitted the unload half outright. A completed load and a completed unload
|
||
each earn a point, on two distinct `revenueChanged` reasons.
|
||
|
||
The comment that stood there claimed "an unload scores through the same event as a load completion
|
||
in the reducer". It does not — `apply.ts` emits `freightUnload` separately. A comment asserting a
|
||
fact about code a few lines away, and wrong.
|
||
|
||
freight share of gross: 39% -> 49%
|
||
|
||
The harness now prints both halves. `strategyBuckets` counted "a scoring game" the same wrong way,
|
||
and so did the test guarding it — so a game that scored only by unloading was bucketed but not
|
||
counted, which is how the fix first showed up as a failure.
|
||
|
||
This matters backwards as well as forwards: the "freight is only 13-18% of gross" finding was read
|
||
from this number, and it is what drove the Gap 12 industry-density change. That decision was taken
|
||
against an instrument reading roughly 60% low.
|
||
|
||
### Turnouts that go nowhere — reported, confirmed, and mostly not the problem
|
||
|
||
Reported from two replays: of 8 turnouts off the Running Track, 2 formed a run-around, 2 served
|
||
industries and 4 went nowhere. Measured across 120 games, that holds exactly:
|
||
|
||
| what a turnout leads to | |
|
||
| --- | --- |
|
||
| part of a run-around | 30% |
|
||
| a stub, but serves an industry | 12% |
|
||
| a stub ending in bare track | 44% |
|
||
| **nothing below it at all** | **15%** |
|
||
|
||
**4.61 wasted turnouts a game.** Then three attempts to stop it, each measured paired over 400
|
||
seeds, and each WORSE than leaving it alone:
|
||
|
||
| attempt | paired change | |
|
||
| --- | --- | --- |
|
||
| no new way down while one leads nowhere | run-arounds → **0** | deadlocked: a run-around needs TWO ways down, and the second cannot be justified by what hangs off the first |
|
||
| first two free, gate the rest | **−0.84 ± 0.53** (t −3.08) | track spend collapsed 15.4 → 4.8 |
|
||
| forbid rail that butts an incompatible card | **−0.62 ± 0.57** (t −2.11) | |
|
||
|
||
The reason is that a turnout is not only a way DOWN. It is also a way UP, and both the east-west
|
||
extension and the closing arc are gated on one existing beyond them — so cutting the turnouts cuts
|
||
the places a siding can rejoin, and the sidings stop forming too. The apparent waste is optionality.
|
||
This also re-confirms, with proper statistics, a note left in the code by an earlier attempt.
|
||
|
||
### Rail that can never go anywhere
|
||
|
||
The one that did work, and only as a tie-breaker.
|
||
|
||
A port facing an EMPTY square is a promise: something may be built there later. A port butting an
|
||
OCCUPIED square whose card has no matching port is not — that square is taken, so the rail stops
|
||
dead and always will. Reported from seed 618682, where an arc came off a turnout with its far end
|
||
jammed into a curve that could not accept it. Measured: **28% of all pieces laid, 4.26 a game.**
|
||
|
||
Forbidding it cost 0.62 revenue a game. Applying it as a **tie-breaker on the distance score** —
|
||
never able to veto a piece, only to choose between two the heuristics rate equally — measured
|
||
**+0.43 ± 0.49 (t 1.74)**, with 108 seeds better against 67, and cut these from 28% of pieces to 7%.
|
||
Not significant on its own, but it is the only one of four attempts pointing the right way, and the
|
||
mechanism is sound.
|
||
|
||
**And it fixed the reported problem after all — sideways.** Re-running the turnout taxonomy:
|
||
|
||
| what a turnout leads to | before | after |
|
||
| --- | --- | --- |
|
||
| part of a run-around | 30% | **66%** |
|
||
| a stub, but serves an industry | 12% | 13% |
|
||
| a stub ending in bare track | **44%** | **2%** |
|
||
| nothing below it at all | 15% | 18% |
|
||
| **wasted per game** | **4.61** | **1.65** |
|
||
|
||
Bare stubs all but gone and run-arounds more than doubled, without ever refusing a turnout. Refusing
|
||
them directly had destroyed the run-arounds; declining to lay rail INTO a dead end leaves the bot
|
||
free to cut every turnout it likes and quietly stops it building the stubs. The remaining waste is
|
||
the last turnouts of a game, cut with no turns left to build beneath them.
|
||
|
||
Also added, and honest about it: a turnout is not cut when no arc remains to hang beneath it.
|
||
Measured at **0%** today — the bot lays the arc immediately after the turnout and never runs the
|
||
supply dry — so it is a guard against the supply changing rather than a fix for anything happening
|
||
now. Its test constructs the situation by draining the arcs.
|
||
|
||
### A source file git was hiding
|
||
|
||
Found while checking the above: **`src/web/replays.html` had never been committed.** `.gitignore`
|
||
carried `replay*.html` to catch the throwaway files generated at the repo root, and unanchored it
|
||
also matched a source page — the website's replay viewer. `git archive HEAD` confirms it: a fresh
|
||
clone does not contain that file, and `build-web.ts` copies it unconditionally, so the build would
|
||
have failed for anyone but this working copy.
|
||
|
||
The pattern is anchored to the root now (`/replay*.html`), which still ignores the generated files
|
||
and no longer ignores the source. A test asks GIT — not `.gitignore` — whether each source page
|
||
would survive a clone, because that is the actual question.
|
||
|
||
### Three places draw a game, and they had drifted
|
||
|
||
Reported from playtesting: the replay had lost its "extra slow" speed, and neither the sound nor the
|
||
auto-hide could be found. Both true, and the same cause — a game is drawn in THREE places and only
|
||
some of them had kept up:
|
||
|
||
| | speeds | sound | auto-hide |
|
||
| --- | --- | --- | --- |
|
||
| the playable page | — | yes | yes |
|
||
| the standalone replay file | 5 | yes | yes |
|
||
| **the website's replay viewer** | **3** | **no** | **no** |
|
||
|
||
The website viewer is its own implementation — it replays a save through the engine in the browser
|
||
rather than reading a rendered file — and it never got what the other two grew. It now has all five
|
||
speeds (extra slow through very fast), the sound, and the auto-hide, using the same shared `cuesFor`
|
||
and `playCue` as everywhere else.
|
||
|
||
Three tests hold them together from now on: the two viewers must offer the SAME set of speeds, all
|
||
three pages must carry the sound and auto-hide controls, and `replays.ts` may not ask for an element
|
||
its page does not have — the same total check the playable page already had, and the one that would
|
||
have caught this. Verified by removing a speed and a control and watching them fail.
|
||
|
||
### The published replays had stopped replaying
|
||
|
||
Both saves in `public/replays/` were dead. A save is a seed plus the intents, replayed through the
|
||
real engine — so it cannot describe a position the rules could not produce, and an intent that no
|
||
longer applies stops the replay rather than being forced. That is the safe direction, but it is
|
||
silent: `seed-202` got 42 intents into 360 before halting, and `seed-430` managed 4 of 338.
|
||
|
||
They were recorded before this run's rules work — Modifier hosts, the industry-to-car mapping, the
|
||
Classification Yard — so most of what they described is no longer legal. Replaced with three games
|
||
generated against the rules as they stand, each verified to replay every intent to the final Day:
|
||
|
||
| | |
|
||
| --- | --- |
|
||
| A winning run | 36 Revenue, seed 1038389 |
|
||
| A strong run | 32 Revenue, seed 618682 |
|
||
| Collisions | 10 Revenue and 27 smashes, seed 919604 |
|
||
|
||
The third is deliberately a bad game: a full Office is a collision, and it costs more than the
|
||
freight was worth.
|
||
|
||
This is the "save/restore is not version-aware" item in TODO doing exactly what it warns about. The
|
||
saves are cheap to regenerate, so the fix is not to freeze them — it is for a stale save to say so
|
||
instead of quietly ending early.
|
||
|
||
### The replay behaves like the game it is replaying
|
||
|
||
Sound and the district auto-hide were built for the playable page and the replay had neither, which
|
||
made watching a game back a poorer experience than playing it. Both are there now, and both are the
|
||
SAME implementation rather than a second copy:
|
||
|
||
- `cuesFor` decides what happened — a Stage ended, a Day turned, a train was built — and is imported
|
||
by the live game and called by the replay recorder, so the two cannot disagree about when a Stage
|
||
ended. Frames carry their cues.
|
||
- `playCue` decides what that sounds like. It was three module-level functions with a shared
|
||
`AudioContext`; it is now one self-contained function, embedded into the replay by `toString()`
|
||
exactly as the board renderers are. The context is parked on `window` because the embedded copy
|
||
has no module scope to keep it in.
|
||
|
||
**Sounds fire only when stepping FORWARD one frame.** Scrubbing across a hundred frames would
|
||
otherwise fire a hundred whistles at once, and stepping backwards would sound a Stage ending that is
|
||
being un-done.
|
||
|
||
Both default to the quiet, tidy setting: muted, and auto-hide on.
|
||
|
||
The replay's DOM stub had no `classList`, so the page threw while rendering rather than folding a
|
||
panel. It has one now — the same gap the playable page's stub had, and the same fix.
|
||
|
||
### Playtest fixes
|
||
|
||
**The end-of-line labels were cut to three letters.** "trains enter" and "trains leave" hang off the
|
||
ends of the route, and the canvas padding was sized for the buffer stops alone — so the west end read
|
||
"TER" and the east "tra". Padding now allows for the words.
|
||
|
||
**"End my turn" became "End Local Operations".** It ends the phase, and the old wording invited the
|
||
reading that it ended something smaller.
|
||
|
||
**The hand limit is a limit, not a toll on drawing.** Drawing a card set "you must now play one",
|
||
so a player who had already played two cards and drew back to three was still forced to spend one.
|
||
§6.2 is a hand LIMIT — "reduce his hand to no more than three cards", four with a Red Flag — and
|
||
that is now the only thing that blocks the end of a turn. It is derived from the hand at each
|
||
render, and a test asserts the page and the engine never disagree about whether the turn may end.
|
||
The engine removes `draw.end` outright when the hand is over the limit, so the page draws a disabled
|
||
button naming the reason rather than silently offering no way out.
|
||
|
||
**A train now says what its card calls for.** Reported on seed 22222: Extra X22 offered a caboose
|
||
and nothing else with no reason given. The rules were right — "Pee-Dee" is a per-diem train whose
|
||
consist is one caboose and no cars, and §8.2 forbids the wrong cars however few are carried — but
|
||
the screen never said so, which reads as a broken game. The New Train header now names the train and
|
||
its consist: *Making up Extra X22 "Pee-Dee": its card calls for 1 caboose — Per-diem train. May only
|
||
pick up MTs.*
|
||
|
||
**The auto-hide control said what the panel was doing, not what pressing it would do.** "auto ·
|
||
folded" reads as a status line and was missed; it now reads "auto-hide: on — click to keep open",
|
||
and looks like a control rather than a caption.
|
||
|
||
**The whistle is twice as long,** and sound now defaults to OFF.
|
||
|
||
### A legal move you could not click
|
||
|
||
Reported from playtesting: laying a straight offered three places in the list and highlighted one on
|
||
the board.
|
||
|
||
Empty squares were drawn by a separate `ghostSvg` and spliced into the SVG afterwards. It computed
|
||
its origin over cells PLUS the offered spots, while `officeSvg` sized itself over cells alone — so
|
||
the two disagreed the moment a legal square lay outside the played cards, which is every square that
|
||
would EXTEND the district. Reproduced on seed 555: three placements offered, the empty one drawn at
|
||
y=99 on a canvas 103 tall, i.e. off it entirely.
|
||
|
||
`officeSvg` now takes the ghosts and sizes itself over both, so there is one origin and one canvas,
|
||
and `ghostSvg` is deleted rather than fixed — a second coordinate system was the bug, not a detail
|
||
of it. It was also imported by the replay and never called there.
|
||
|
||
A test walks six seeds and asserts every square the menu offers has a target drawn inside the
|
||
canvas: 129 squares across 47 placeable subjects. It fails on the old code with the exact
|
||
coordinates above.
|
||
|
||
### The railroad, heard
|
||
|
||
A whistle at the end of each Stage, the grade-crossing bell at the end of each Day, and the
|
||
conductor when a train is built. **Off by default** — everything here is synthesised rather than
|
||
recorded, so it is a placeholder for real audio and a playtester who did not ask for noise should
|
||
not get any. One click in the title bar turns it on, and that click doubles as the gesture browsers
|
||
require before audio may start.
|
||
|
||
**Synthesised, not sampled**, and that is a constraint rather than a preference: the site is a static
|
||
folder that fetches nothing — there is a test asserting no page reaches an external host — so audio
|
||
would have to be committed to the repo, and a plausible whistle is not something to invent. A real
|
||
steam whistle is a CHORD of several chambers slightly out of tune with each other plus the breath of
|
||
the steam, which three detuned partials and a band of filtered noise get most of the way to. The
|
||
bell is inharmonic partials struck twice, which is what separates a bell from a beep.
|
||
|
||
**"All aboard" is speech, and speech cannot be faked with oscillators.** `speechSynthesis` is built
|
||
into the browser, needs no asset and works offline, so it says the words; where the platform has no
|
||
voice installed a two-note conductor's call takes its place rather than nothing happening.
|
||
|
||
The model names WHAT happened — a Stage ended, a Day turned, a train was made up — and the page
|
||
decides what that sounds like. A Day boundary rings the bell only: both would collide, and the bell
|
||
is the bigger event. The first Stage of the game announces nothing, because a Stage beginning is the
|
||
previous one ending and there is no previous one.
|
||
|
||
Counted against the clock rather than trusted: over a full game, 60 Stage boundaries produced 55
|
||
whistles and 5 bells, and 5 Days produced 5 bells.
|
||
|
||
### Regions, drawn from what the crossing already cost
|
||
|
||
§2.1 divides a Mainline card into two regions and §8.2 moves a train one region per Stage. The
|
||
engine had replaced that with `crossingStages()` — `implications.md` records it plainly: "The
|
||
`Region` model is gone" — because ten card types with real speeds cannot be expressed by one region
|
||
a Stage. Both `REGIONS_PER_MAINLINE_CARD = 2` and `entryPoints` survived as dead constants.
|
||
|
||
The map draws regions again without reinstating the mechanic, because the printed cards say how:
|
||
they carry **Start positions**, so a train with Brakemen *enters further along the card*. That is
|
||
the same fact as "takes a Stage off the crossing", in different coordinates. So position falls out
|
||
of what the engine already knows:
|
||
|
||
entry = REGIONS - stagesTotal position = clamp(entry + elapsed)
|
||
|
||
| crossing | drawn as | |
|
||
| --- | --- | --- |
|
||
| 1 Stage (a 60 card) | enters at region 2, gone next Stage | the printed Start position |
|
||
| 2 Stages (a 30 card) | region 1 → region 2 | **§8.2 exactly** |
|
||
| 3 Stages (slow train) | region 1 → region 1 → region 2 | fixed distance, slow train |
|
||
|
||
`entry` is deliberately allowed to go negative and only the final position is clamped: that keeps a
|
||
slow train's extra Stage at the START, where being slow shows. Clamping the entry instead parked it
|
||
at the exit, reading as a train that raced across and then waited — which is what the first version
|
||
did, and what the test now forbids.
|
||
|
||
`Transit` gains `stagesTotal`, set on entry. State, not rules: nothing reads it to decide anything,
|
||
so the bot and every balance figure are untouched, and a save is a seed plus intents so there is
|
||
nothing to migrate. It cannot be recomputed later — a modifier played onto the card mid-crossing
|
||
would change the answer and make the train jump backwards.
|
||
|
||
Division Points draw one region, the queue. Running Track cards inside a district draw none: a crew
|
||
moves there by Moves, not Stages, so it occupies a card outright.
|
||
|
||
### The Division map shows the whole route
|
||
|
||
The map drew one box per node, which collapsed each player's entire district into a single "Office"
|
||
tile — so the track a train actually runs along was invisible on the only view that shows where
|
||
trains are.
|
||
|
||
An Office now expands into its **Running Track, Limits to Limits**. That is the right cut rather than
|
||
a compromise: the through route IS the Running Track, and everything hanging beneath it is secondary
|
||
track a crossing train never touches. Sidings, industries and the load pipeline stay in the district
|
||
view, which is where they can be read.
|
||
|
||
West DP · Mainline · [Limits … Office … Limits] · Mainline · [ … ] · Mainline · East DP
|
||
|
||
**Trains are shown wherever they are.** On a Running Track card, on a Mainline card, queued at a
|
||
Division Point. A crew working BELOW the Running Track has no position on the through route, so it
|
||
is reported against the district — "2 switching below" — rather than drawn somewhere it is not.
|
||
Projecting a siding onto the through line would be a lie the map cannot support.
|
||
|
||
**Seating.** Players sit around a table, so the route is laid out the way they do: one row alone,
|
||
two rows facing, a horseshoe of three, a square of four. The Division is a LINE and not a loop —
|
||
trains enter at one Division Point and leave at the other — so the shape is deliberately left open,
|
||
with buffer stops at both ends and the gap labelled "trains enter" and "trains leave". Closing it
|
||
into a ring would promise a connection the rules do not have.
|
||
|
||
Layout is geometry with no visual feedback loop, so it is tested rather than eyeballed: for 1, 2, 3
|
||
and 4 players no two cells may overlap and none may fall outside the canvas.
|
||
|
||
### The district folds itself away
|
||
|
||
Auto-focus. The district is worth its vertical space during the phases that change it — Local
|
||
Operations and Cargo — and not during New Train, Mainline or Supervisor Shift, where the Division
|
||
map is what matters. Folded, it leaves a summary line rather than vanishing, because a panel that
|
||
disappears entirely reads as broken.
|
||
|
||
A manual toggle overrides it and stays put. This is DISPLAY state and never game state: two players
|
||
at the same table may reasonably want it set differently.
|
||
|
||
### The replay was storing the same things over and over
|
||
|
||
Halved, near enough: **3415 KB → 1877 KB** on a 735-frame game.
|
||
|
||
The TODO said to carry `links` forward. Measured first, that would have bought 5% of the `cells`
|
||
payload — `cells` is 62% of the file, and inside it the cost is elsewhere:
|
||
|
||
what 32% of cells long prose, identical on every turnout in the district
|
||
facility 24% of cells the SAME object already serialised in the frame's `facilities`
|
||
identity 26% of cells row/col/kind/label/running/links, fixed once the card is laid
|
||
|
||
So all three are interned. A card's identity and its description are written once for the whole
|
||
recording and referenced by integer, and a cell points at its facility instead of carrying a second
|
||
copy of it. What stays per-frame is what genuinely changes: the cars standing there, the crew, and
|
||
any Enhancement laid on the card.
|
||
|
||
`rehydrateCells` is exported and emitted into the page by `toString()`, the same trick the two board
|
||
renderers use — a second copy inside the page's inline script could drift from the packing and the
|
||
symptom would be a board drawing the wrong cards rather than an error. The round-trip test runs that
|
||
exact function over every frame, resolving nulls the way the page does.
|
||
|
||
### Rolling stock was leaving the game
|
||
|
||
Four things were tried against bot revenue. **One of them was worth more than everything else in
|
||
this changelog combined, and it is the one that had been ranked third and predicted not to matter.**
|
||
|
||
**The Classification Yard was write-only.** Seven places pushed cars into it — retired trains,
|
||
collisions, unjams, set-outs — and nothing in the engine ever read it. Rolling stock drained one way
|
||
out of the game: 30 cars dead by the end of a game, **37% of the 80 dealt at setup**. Gap 2c says
|
||
engines and cabooses return to the Division Yard and everything else to Classification; the
|
||
recovered rules never say how Classification empties.
|
||
|
||
**ASSUMPTION, flagged rather than derived:** sorting cars for redistribution is what a
|
||
classification yard is for, and a Day is its natural cycle, so they now return at the Day boundary.
|
||
This is a rules decision that wants confirming against the source.
|
||
|
||
Paired over 400 seeds: **+2.32 ± 0.52, t = 8.79**, 194 seeds better against 44. Revenue 6.70 → 9.02.
|
||
|
||
Why it was mis-ranked is worth recording. The Division Yard does not run *dry* in five Days — 16.6
|
||
loaded freight cars remain, empty in 2 games of 100 — so it was reasoned that supply could not be
|
||
binding. The aggregate was never the point: what starves freight is not having the RIGHT commodity
|
||
at the moment a green box needs stocking, and returning classified cars keeps the mix alive.
|
||
|
||
### Three things that did not work, kept because the measurement is the result
|
||
|
||
Measured paired over the same 400 seeds, which is the only way to see effects this size.
|
||
|
||
**Capping the draw option: worth nothing.** 62% of all Local Operations actions went to drawing and
|
||
12.6 of 29 cards drawn were discarded, so a draw into a full hand converts straight into a discard.
|
||
Refusing it: **-0.10 ± 0.13**, and 379 of 400 seeds byte-identical. The branch almost never fires.
|
||
|
||
**Pairing the two halves of a load: worth nothing.** Sampling every outbound industry at every
|
||
loadUnload phase, 76% of the time it had NEITHER a stocked green box NOR a spotted car, and only 5%
|
||
of Stages had a single workable facility. Letting the bot switch for a stocked box without waiting
|
||
for a train at the Office changed nothing measurable — folded into the -0.10 above. The diagnosis
|
||
was right and the prescription did not address it: there is usually nothing to switch.
|
||
|
||
**Waking a dead branch made things worse.** `chooseLocalOption` tested
|
||
`options.some(i => i.type === 'mainline.modify')`, but that intent requires `s.turn.option === 'draw'`
|
||
and the test runs while the option is still null — `legalActions` had already filtered it out, so the
|
||
branch could never fire and never had. Rewriting it to check the hand cost **-0.64 ± 0.32
|
||
(t = -3.90)**, 104 seeds worse against 35. Its comment claimed the value compounds like a train
|
||
card's; it does not. The branch is now deleted, with the measurement in its place.
|
||
|
||
**The Enhancements other than Interlocking are worth exactly nothing** — and cost nothing either:
|
||
**-0.01 ± 0.41 (t = -0.04)** when the bot is forbidden to place any of them. Left as they are; there
|
||
is nothing to gain by restricting them. Note the first attempt at this experiment showed 0/400 seeds
|
||
changed, which was the experiment failing rather than the answer: a `card.play` fallback with no
|
||
placement filter was still placing them.
|
||
|
||
| | before | after |
|
||
| --- | --- | --- |
|
||
| revenue (200 games) | 6.7 | **8.7** |
|
||
| wins | 6.5% | **12%** |
|
||
| freight loads | 2.8 | 3.2 |
|
||
| cars dead in the Classification Yard | 28.4 | **~0** |
|
||
|
||
### A Running Track with nowhere to put an Enhancement
|
||
|
||
Every penalty in the game has one cause. Across 100 games, all 48 were `collision: no free A/D
|
||
track` — 2.70 revenue a game, **27% of gross**, concentrated in about a fifth of games and
|
||
responsible for the −47 tail.
|
||
|
||
The rules already answer it. Interlocking prints "may stop an inbound train on the Limit Track", and
|
||
`advance.ts:621` holds the train at the Limits instead of colliding. **It had never once been
|
||
placed.** Nor had any train ever been held at the Limits.
|
||
|
||
The reason was structural and nothing to do with Interlocking. The bot builds minimal two-arc
|
||
run-arounds — an `ne` arc meets an `nw` arc directly — so it never needed a straight and laid none:
|
||
**0.00 straights on the Running Track across 100 games.** Interlocking, Water Column and Telegraph
|
||
all require one; Yard Office and Small Yard want a Secondary Track Straight; Telephone and Radio
|
||
chain off Telegraph. Thirteen of the eighteen Enhancement cards that go on the board were
|
||
unplayable. They were drawn 3.78 a game and placed 0.64.
|
||
|
||
`bestTrackLay` now scores one straight onto the Running Track. Enhancements placed went 0.64 → 3.01
|
||
across nine types where only Overpass had ever appeared, and Interlocking now reaches the board in
|
||
about a quarter of games.
|
||
|
||
**On whether it pays — measured properly, and the answer is qualified.** Revenue per game has a
|
||
standard deviation of ~9, so a 100-game run carries roughly ±1.0 of noise. Run against the same 400
|
||
seeds, paired:
|
||
|
||
| | without | with |
|
||
| --- | --- | --- |
|
||
| revenue | 5.99 | **6.70** |
|
||
| collisions cost | 2.70 | **1.91** |
|
||
| worst single game | −47 | **−24** |
|
||
| most collisions in a game | 10 | 6 |
|
||
| wins | 5.3% | 6.5% |
|
||
|
||
Paired per-seed the change is **+0.70 ± 0.74 (95% CI), t = 1.87** — not significant on its own. And
|
||
154 seeds improved against **165 that got worse**: the mean gain comes from removing catastrophes,
|
||
not from making a typical game better. What justifies keeping it is that the mechanism is measured
|
||
directly and accounts for the whole effect — collision cost falls 0.79, and revenue rises 0.70.
|
||
|
||
**A correction to the numbers already in this file.** The per-100-game revenue figures reported for
|
||
the earlier changes carry the same ±1.0 noise, so the individual steps (5.0 → 6.0 → 6.5) were stated
|
||
more precisely than the sample supports. The cumulative move from 3.2 to ~6.7 is far larger than the
|
||
noise and stands; the individual increments should be read as indicative only.
|
||
|
||
### The bot was throwing away its own freight
|
||
|
||
**Routing turned out not to be the problem, which is why it was worth measuring first.** Of 1650
|
||
drops across 100 games, 940 (57%) landed on a facility that wanted the car and **zero** landed on
|
||
one that did not. The crew makes 9.4 correctly-targeted drops a game; the one-move-only destination
|
||
test costs nothing measurable. "70% of waiting loads have nothing spotted" was a misleading signal —
|
||
the cars arrive.
|
||
|
||
Following the freight from the other end found the leak. Loads were being **destroyed**:
|
||
|
||
stockToOutbound 9.45/game
|
||
loadStarted 2.71/game
|
||
facilityUnjammed 3.10/game from outbound <- healthy waiting loads, discarded
|
||
|
||
`facilityUnjammed` from `outbound` splices the load out of the green box and pushes it to the
|
||
classification yard. That load cost a Local Operations action to stock, so discarding it is strictly
|
||
negative — and the bot did it more often than it started a load.
|
||
|
||
**Two fallbacks meeting, and the root cause is a familiar one: two rules for one act.**
|
||
`canStockProductively` decided the Freight Agent option was worth taking if a matching empty car was
|
||
spotted. The engine's `stockOutbound` additionally requires a **loaded car of that commodity in the
|
||
Division Yard** — which the predicate never checked. So the option was chosen believing a box could
|
||
be stocked when none could; the follow-through then found nothing stuck, nothing to clear and
|
||
nothing stockable, and fell through to "clear whatever is stuck" with nothing stuck.
|
||
|
||
Fixed by making the predicate ask the same question the engine does, by no longer using Freight
|
||
Agent as the idle default (switching at worst moves the crew toward the Office, which a train must
|
||
reach to depart at all, §8.1), and by ordering the last-resort unjam by what it costs to lose —
|
||
MEN|AT|WORK first, then the red box whose Revenue is already banked, and the green box last.
|
||
|
||
| | cars fixed | freight kept |
|
||
| --- | --- | --- |
|
||
| loads discarded from a green box | 3.10 | **0.00** |
|
||
| revenue | 6.0 | **6.5** |
|
||
| wins | 5/100 | **8/100** |
|
||
| trains scheduled | 3.0 | 3.4 |
|
||
| cards played | 16.6 | 19.3 |
|
||
|
||
**The gain is development, not freight.** Loads started held at 2.70 and freight revenue at 2.6 —
|
||
the recovered Local Operations actions went into drawing and switching rather than into the freight
|
||
chain. Green-box stocking fell 9.45 → 6.34 because the bot no longer stocks boxes it cannot serve.
|
||
The regression test asserts outbound unjams stay at zero *and* that genuine MEN|AT|WORK jams are
|
||
still cleared, so gutting the fallback would not pass it.
|
||
|
||
### Half the industries never asked for a car
|
||
|
||
Freight had not moved through two rounds of fixing the district, and this is why: **three of the six
|
||
industries were invisible when the bot chose what to put on a train.**
|
||
|
||
`wantedCars` consulted a hand-written `industry -> car type` switch that had drifted from the sheet.
|
||
It named `produceShed` and `oilRefinery` — neither is an industry — and omitted `freightHouse`,
|
||
`refinery` and `packingSheds`, which are. An industry it could not name returned null and was
|
||
skipped entirely, so it never requested a car. The Refinery is the only source of tank traffic, so
|
||
tank cars boarded a train 0.07 times a game and were **dropped by a crew zero times in 100 games**,
|
||
while 23 of 79 waiting loads sat at an industry that wanted one.
|
||
|
||
It now derives the commodities from `INDUSTRY_PROFILES`, which is the sheet. The switch is deleted
|
||
rather than corrected — a second copy of the mapping is the bug, not the values in it.
|
||
|
||
**A second, narrower collapse.** `facilityCarType` returned `carTypes[0]`, so the second commodity
|
||
of a two-commodity industry was unreachable: a Power Plant burns coal OR oil, a Grocer's Warehouse
|
||
receives dry goods OR perishables. `facilityCarTypes` (plural) now returns the full set, and the
|
||
bot's spotting and switching checks accept any of them.
|
||
|
||
Worth stating precisely: the engine's `WRONG_CAR_TYPE` gate was corrected to use the full set too,
|
||
but that changes nothing today — both two-commodity industries are inbound-only, so
|
||
`freightAgent.stockOutbound` rejects them before the commodity is examined. That fix is latent. The
|
||
measured gain is entirely the bot side.
|
||
|
||
| | facilities fixed | cars fixed |
|
||
| --- | --- | --- |
|
||
| revenue | 5.0 | **6.0** |
|
||
| wins | 1/100 | **5/100** |
|
||
| freight revenue | 1.3 | **2.6** |
|
||
| freight share of gross | 25% | **37%** |
|
||
| loads completed | 1.27 | **2.60** |
|
||
| tank cars dropped | **0.00** | **0.62** |
|
||
| reefers dropped | 0.04 | 0.67 |
|
||
|
||
Both regression tests were checked against the bug they guard: restoring the stale map fails the
|
||
tank assertion, and collapsing `carTypes` to its first entry fails the profile assertion.
|
||
|
||
**Still 70% of waiting loads have nothing spotted at all.** The commodity mix is right now; the
|
||
volume reaching the industry tracks is not. That is a routing question — which facility the crew
|
||
takes a car to — rather than a car-choice one.
|
||
|
||
### Putting the industries on the siding
|
||
|
||
The run-arounds were being built and the industries were somewhere else — facilities sitting on one
|
||
stayed at **0.00 a game** even at 91 districts in 100 with a closed loop. The cause was blunt:
|
||
facility placement was `options.find(i => i.placement !== undefined)`, the first legal square the
|
||
generator happened to list, unscored, while track laying had sixty lines of scoring beside it.
|
||
|
||
**Facilities are now scored**, on the thing that decides whether a crew can serve them at all: a
|
||
placement in line with a siding still being built becomes part of the loop itself (its own through
|
||
track is a segment), so the crew reaches it from either end and can pass its standing cars (§A.5).
|
||
Merely touching reachable track is worth less; the Running Track is a penalty, because a car left
|
||
standing there is hit by the next arrival (§11.2).
|
||
|
||
**A second bug surfaced immediately, and it is the interesting one.** Scoring facilities onto the
|
||
siding row sent run-arounds *down*, 91 games in 100 to 36 — the industry took the square and the
|
||
loop stopped closing around it. `runsAcross` tested the card's KIND ("a track straight running
|
||
east-west"), so an industry standing in the line read as a dead end and the run refused to extend
|
||
through it. It now asks the card's PORTS instead. A Facility carries its own rails (§11.2), and so
|
||
do the Office and a Limits sign; what matters is whether a port faces this way.
|
||
|
||
The reachability walk is now shared between track laying and facility placement rather than written
|
||
twice — two copies would eventually disagree about whether a district connects, which is the one
|
||
thing both decisions rest on.
|
||
|
||
| | before sidings | sidings fixed | facilities fixed |
|
||
| --- | --- | --- | --- |
|
||
| facilities on a run-around | 0.00 | 0.00 | **1.08** |
|
||
| games with a run-around | 0/100 | 91/100 | 71/100 |
|
||
| revenue | 3.2 | 4.1 | **5.0** |
|
||
| collisions | — | — | 0.4 (was 0.6) |
|
||
| track pieces spent | 19.5 | 16.1 | 13.6 |
|
||
|
||
Run-arounds fall from 91 to 71 because facilities now compete for the siding squares — which is the
|
||
trade being made deliberately: a loop with an industry on it is worth more than an empty one.
|
||
|
||
**Freight did not follow.** Loads completed 1.42 → 1.27 and freight's share of gross 31% → 25%; the
|
||
revenue gain is passengers and fewer collisions. Of facilities holding a load, 77% still have
|
||
nothing spotted at all. The industries are now reachable and the right cars still are not arriving —
|
||
which is the car-selection problem in TODO, untouched by any of this.
|
||
|
||
### The bot was building stubs, not sidings
|
||
|
||
**Measured first: 0 run-arounds in 100 games.** A run-around is the engine's own definition of a
|
||
useful siding (`track.ts`) — double-ended, both ends reaching the main, and §A.5's facing-point move
|
||
is impossible without one. Every district the bot built was dead-end stubs, 3.86 of them a game,
|
||
plus 2.89 cards below the main that reached nothing at all. Before trusting a zero the detector was
|
||
handed a run-around built on purpose and found it from both ends.
|
||
|
||
**Three bugs, all the same shape: scoring on local form without checking it reaches anything.**
|
||
|
||
- The +12 rule was commented "close the loop back up to the main: the run-around is complete" and
|
||
only tested that a neighbour ran east-west — never that the card above had a south port to join.
|
||
The siding terminated in an arc pointing north into empty space. It now requires a way up above,
|
||
asked of the engine's `hasPort` so the Office counts too; `divergesSouth` had looked for a turnout
|
||
and missed the one way down that is on every board. An arc's facing also decides what it meets —
|
||
`nw` joins west, `ne` joins east — so closing from the wrong side connected nothing.
|
||
- The east-west extension had no stopping condition, so the run went on past the last column it
|
||
could rejoin at, in 96 of 100 games. The loop then missed by one card.
|
||
- **`bestTrackLay` never declined.** This was the one that actually mattered, and the first two
|
||
fixes barely moved the overshoot without it: the function returned its best-scoring option
|
||
unconditionally, so once the useful squares were taken it kept laying track because track was
|
||
legal. Bonuses are now tracked apart from the distance score, and a piece that earns none is not
|
||
laid — the Stage falls through to playing a card instead.
|
||
|
||
Anchors also have to be reachable from the main now. A stranded east-west straight made both its
|
||
neighbours look like legal extensions, so a fragment joined to nothing grew in both directions.
|
||
|
||
| | before | after |
|
||
| --- | --- | --- |
|
||
| games with a run-around | **0/100** | **91/100** |
|
||
| track laid east of the last way up | 96/100 | **0/100** |
|
||
| track pieces spent | 19.5 | 16.1 |
|
||
| revenue | 3.2 | **4.1** |
|
||
| freight revenue | 2.6 | **3.8** |
|
||
| cars dropped | 11.6 | 20.9 |
|
||
| loads completed | 0.99 | 1.42 |
|
||
|
||
Two regression tests, both walking the district with the engine's own `exitsFrom` so they cannot
|
||
credit a connection §A.1 forbids: one asserts run-arounds get closed, the other that no track is
|
||
spent east of the last column with a way up.
|
||
|
||
**Still open.** Facilities sitting *on* a run-around: 0.00. The loops get built and the industries
|
||
are not on them, so the run-around is not yet paying for itself in freight — which is the next
|
||
thread, not a finished one.
|
||
|
||
### Freight, drawn where the work happens
|
||
|
||
**The load pipeline moved onto the card.** A load crosses green → `MEN | AT | WORK` → a spotted car,
|
||
and that journey *is* freight. It was drawn only in the side panel, so a Laborer action — the whole
|
||
of the freight game — changed nothing on the card the player was looking at. The squares now sit
|
||
under the track, which is where the printed cards put them and why they are printed at all. The
|
||
tooltip names the same thing in words: which square the load is on, and what the next Laborer action
|
||
does with it.
|
||
|
||
**A collision found while placing them.** `overpass` and `facingPointLocks` are placed `onCard`, so
|
||
they can land on a facility, and the enhancement label's baseline ran straight through the new
|
||
squares. The label moved into the gap between the crew tray and the pipeline; a test now asserts the
|
||
two do not overlap rather than trusting the two constants to stay apart.
|
||
|
||
### Teaching the bot to use a siding
|
||
|
||
**Nose coupling, which the rules had and the engine did not.** §A.3: "engines also have couplers on
|
||
the front end, so a train can pick cars up onto its nose". Coupling always appended to the back, so
|
||
which end cars landed on did not exist — and that is precisely what a run-around is for. Cars met
|
||
running FORWARD now couple in front, cars met BACKING couple behind, so the approach decides which
|
||
car is next off the tail:
|
||
|
||
running forward -> reefer, boxcar, hopper next off: hopper
|
||
backing up -> boxcar, hopper, reefer next off: reefer
|
||
|
||
The bot prefers a run-around to setting a car down, since it keeps the car — but only when the drop
|
||
can actually follow. Without that guard it ran the loop for its own sake (8 a game, 63 Moves), which
|
||
the shuttling regression correctly failed.
|
||
|
||
**A serious bug found on the way.** The `carsCoupled` reducer cleared **every card in the Office
|
||
Area**, not the ones the crew ran over — its own comment said "every card along the path" while the
|
||
code iterated the whole grid. One coupling anywhere deleted every standing car and every industry
|
||
track in the district, so loads worked over several Stages vanished when a crew picked up an
|
||
unrelated boxcar somewhere else. The event now names the cards it lifted from.
|
||
|
||
**A test replaced rather than relaxed.** "Under 60 Moves a game" started failing. That threshold was
|
||
calibrated when the crew coupled ~0.4 cars a game; it now couples ~8 and drops ~11, and a run-around
|
||
is *supposed* to cost several Moves. Counting Moves was always a proxy for aimlessness, so the test
|
||
now measures the thing itself — Moves per drop-or-coupling — which still fails when the bot is made
|
||
to wander.
|
||
|
||
| | before sidings | now |
|
||
| --- | --- | --- |
|
||
| revenue | 3.9 | 3.2 |
|
||
| freight | 0.9 | 1.0 |
|
||
| drops per game | 3.3 | **11.1** |
|
||
| couplings per game | ~0.4 | **7.9** |
|
||
|
||
Switching is transformed; revenue is not. The crew now does real work — roughly 4 Moves per
|
||
productive act, which is what running a loop costs — but that work is not yet converting into
|
||
Revenue. Where it goes next is an open question rather than a known fix.
|
||
|
||
---
|
||
|
||
## Board rendering, three-page site, curve geometry
|
||
|
||
Drawing the board as track, splitting the site, tooltips, and the curve fix.
|
||
|
||
### Board rendering
|
||
|
||
Two renderers in `src/sim/board-svg.ts`, chosen because they fail in opposite places:
|
||
|
||
- **Office Area** — the district stays a map. Cards on a grid with the rails drawn edge to edge, so
|
||
a join is rail meeting rail rather than two descriptions that happen to agree. The through rail
|
||
sits at a constant height on every card, which is the alignment the printed cards use.
|
||
- **The Division** — not a map but a queue of sections with hard capacities, so it is a dispatcher's
|
||
diagram: one line per track, `1 of 2 free` under each section, `no limit — trains queue` at the
|
||
Division Points.
|
||
|
||
Both are **self-contained** — no imports, no module-level helpers — because the playable app imports
|
||
them normally while the replay is a single HTML file with an inline script that cannot import
|
||
anything, and embeds them via `Function.toString()`. One implementation either way; a second copy
|
||
would eventually draw a different board from the same state.
|
||
|
||
Rails come from the engine's own `connectionsFor`, now exported. A drawn rail cannot claim a
|
||
connection the rules do not have — visible in that **Modifier cards draw no rails at all**.
|
||
|
||
Capacity, confirmed from the rules and now shown: Division Points unlimited, most Mainline cards 1,
|
||
Double Track and Uncontrolled Siding 2, Offices 1/2/3/4 by tier.
|
||
|
||
### Three pages
|
||
|
||
`index.html` is now a splash with two doors. The game moved to `play.html`; `replays.html` is a
|
||
directory.
|
||
|
||
**A replay is a save**, and a save is `{seed, history}`. The engine is deterministic and runs in the
|
||
browser, so re-submitting the same moves rebuilds the position exactly — **18 KB instead of 3.4 MB**,
|
||
about 190× smaller, small enough to email. A save that would describe an impossible position cannot
|
||
be replayed at all, because every step goes back through `applyIntent`; the viewer stops and says so
|
||
rather than showing a board the rules could not produce.
|
||
|
||
Static hosting cannot list a directory, so `replays/manifest.json` is generated at build time from
|
||
whatever is in `public/replays/`, with each file validated first.
|
||
|
||
### Tooltips
|
||
|
||
Reference detail is read once and printing it costs the space the board and action list need. A
|
||
single delegated tooltip (`src/web/tooltip.ts`) now carries card effects, action reasoning, and
|
||
facility state. Not the native `title`: that waits a second, cannot be styled, and never appears for
|
||
keyboard users.
|
||
|
||
### Curve geometry — the significant fix
|
||
|
||
A curve was modelled as a through track **plus** a diverging leg, which is a turnout. Consequences:
|
||
|
||
- Curves were **topologically identical duplicates of turnouts** — same connections, no distinction
|
||
beyond the sharp curve's 2-Move cost.
|
||
- Every diverging leg went south, so **no piece anywhere reached north** except an n-s straight,
|
||
which connects n↔s and nothing else.
|
||
- Therefore a district could only ever be a **vertical column**: no siding, no parallel track, no
|
||
run-around, no second turnout back to the main.
|
||
|
||
The printed cards (`docs/tracks.png`, rows 3–4) show a curve as a single arc from edge to edge with
|
||
no through track. A curve is now a **two-port arc**, rotatable to `ne`/`nw`/`se`/`sw`. A sharp curve
|
||
is geometrically identical and costs 2 Moves. Turnouts keep §A.1 unchanged — a two-port arc has no
|
||
third port, so the absent-edge rule cannot apply to it.
|
||
|
||
Verified through the engine, not the types: a crew leaves the main at a turnout, runs a siding
|
||
parallel, and rejoins at the far end.
|
||
|
||
### Measurements
|
||
|
||
| | revenue | freight | sidings built |
|
||
| --- | --- | --- | --- |
|
||
| before | 4.1 | 0.5 | impossible |
|
||
| geometry only | **3.9** | 0.9 | possible, not sought |
|
||
| bot builds sidings | 3.3 | **1.1** | **59/60 games** |
|
||
|
||
Freight more than doubled and the harness recorded **its first win**, but overall revenue is down
|
||
from geometry-alone and the bad tail worsened (−29 → −59).
|
||
|
||
Capping turnouts at one or two measured **worse** (revenue 2.5, freight 0.6) — more ways off the main
|
||
means more industries the crew can reach, and that beats a tidy Running Track. The uncapped version
|
||
stands, with that measurement recorded at the code.
|
||
|
||
**The bot builds sidings but does not exploit them.** It pays about 20 of its 26 track pieces for
|
||
them while its switching logic still sets out dead weight on a spur rather than planning a
|
||
run-around. That is the next piece of work and where the revenue should appear.
|
||
|
||
---
|
||
|
||
## Earlier commits
|
||
|
||
Recorded from memory of the work rather than written at the time; detail thins going back.
|
||
|
||
### `f00255c` — more fixes and tweaks
|
||
|
||
Card descriptions on every card in hand and every face-up slot, the three Local Operations options
|
||
explained, the objective and pace in the header, and rotations named in words rather than
|
||
"rotation 2". Build stamp added (version, git SHA, `-dirty` for an uncommitted tree) because nothing
|
||
tracked what was deployed. Extra X22 was **not** a bug — a per-diem train whose card calls for a
|
||
caboose and nothing else — but "loaded caboose" was.
|
||
|
||
### `d1f689d` — name the caboose, the train and the Running Track hazard
|
||
|
||
`maneuver.redFlags` was labelled "Red Flags on tray3": the earlier tray-id fix checked the history
|
||
log, and the action list was a surface it missed. An industry on the Running Track does not block
|
||
traffic (§11.2 gives it rails) but a car left standing there is hit by the next arrival (§10).
|
||
|
||
### `dd300ac` — fix caching, Limits placement, and explain the board better
|
||
|
||
The first deploy served a fresh `index.html` against a **cached** `main.js`, which threw
|
||
`missing element: target` and never started. Every module import now carries the build tag. The
|
||
Limits fix was half-done: the sign could move, but nothing forbade building past it, so one straight
|
||
at (0,2) produced `limits · Whistle Post · limits · straight · limits`.
|
||
|
||
### `2eca9de` — playable browser build
|
||
|
||
The solitaire game as a static site, proven to need no server: full games run with every Node global
|
||
replaced by a throwing stub. Train cards stopped accepting a board placement — seed 555 had offered
|
||
Extra X15 at six squares with six rotations, all identical.
|
||
|
||
### `160190d` — the bot's reasoning in the replay
|
||
|
||
Decision panel showing what the bot chose, why, and every option it passed over, with the reasons
|
||
reported by the bot itself rather than re-derived by the viewer.
|
||
|
||
### `d261ad7` — parking trains, freight deadlock, Whistle Post lock-in
|
||
|
||
Three faults found by measurement rather than failing tests. Trains parked because `destinationsFor`
|
||
computed reverse as `facing === 'e' ? 'w' : 'e'`, so a crew facing south reversed to east — a port a
|
||
north-south card does not have. Freight ran at a **3% load completion rate** because a load started
|
||
with no spotted car parks on WORK and locks the industry track, blocking the very car that would
|
||
clear it. Office density doubled after 25 of 100 games never drew a Depot and never escaped a
|
||
one-track Whistle Post.
|