Files
station-master/CHANGELOG.md
T
JesseandClaude Sonnet 5 37b1e5b969 the Roster Pass: every train at the Office visible
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
2026-08-19 17:32:17 -04:00

230 KiB
Raw Blame History

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.

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

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.