57 KiB
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.
Unreleased
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:
cuesFordecides 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.playCuedecides what that sounds like. It was three module-level functions with a sharedAudioContext; it is now one self-contained function, embedded into the replay bytoString()exactly as the board renderers are. The context is parked onwindowbecause the embedded copy has no module scope to keep it in.
Sounds fire only when stepping FORWARD one frame. Scrubbing across a hundred frames would otherwise fire a hundred whistles at once, and stepping backwards would sound a Stage ending that is being un-done.
Both default to the quiet, tidy setting: muted, and auto-hide on.
The replay's DOM stub had no classList, so the page threw while rendering rather than folding a
panel. It has one now — the same gap the playable page's stub had, and the same fix.
Playtest fixes
The end-of-line labels were cut to three letters. "trains enter" and "trains leave" hang off the ends of the route, and the canvas padding was sized for the buffer stops alone — so the west end read "TER" and the east "tra". Padding now allows for the words.
"End my turn" became "End Local Operations". It ends the phase, and the old wording invited the reading that it ended something smaller.
The hand limit is a limit, not a toll on drawing. Drawing a card set "you must now play one",
so a player who had already played two cards and drew back to three was still forced to spend one.
§6.2 is a hand LIMIT — "reduce his hand to no more than three cards", four with a Red Flag — and
that is now the only thing that blocks the end of a turn. It is derived from the hand at each
render, and a test asserts the page and the engine never disagree about whether the turn may end.
The engine removes draw.end outright when the hand is over the limit, so the page draws a disabled
button naming the reason rather than silently offering no way out.
A train now says what its card calls for. Reported on seed 22222: Extra X22 offered a caboose and nothing else with no reason given. The rules were right — "Pee-Dee" is a per-diem train whose consist is one caboose and no cars, and §8.2 forbids the wrong cars however few are carried — but the screen never said so, which reads as a broken game. The New Train header now names the train and its consist: Making up Extra X22 "Pee-Dee": its card calls for 1 caboose — Per-diem train. May only pick up MTs.
The auto-hide control said what the panel was doing, not what pressing it would do. "auto · folded" reads as a status line and was missed; it now reads "auto-hide: on — click to keep open", and looks like a control rather than a caption.
The whistle is twice as long, and sound now defaults to OFF.
A legal move you could not click
Reported from playtesting: laying a straight offered three places in the list and highlighted one on the board.
Empty squares were drawn by a separate ghostSvg and spliced into the SVG afterwards. It computed
its origin over cells PLUS the offered spots, while officeSvg sized itself over cells alone — so
the two disagreed the moment a legal square lay outside the played cards, which is every square that
would EXTEND the district. Reproduced on seed 555: three placements offered, the empty one drawn at
y=99 on a canvas 103 tall, i.e. off it entirely.
officeSvg now takes the ghosts and sizes itself over both, so there is one origin and one canvas,
and ghostSvg is deleted rather than fixed — a second coordinate system was the bug, not a detail
of it. It was also imported by the replay and never called there.
A test walks six seeds and asserts every square the menu offers has a target drawn inside the canvas: 129 squares across 47 placeable subjects. It fails on the old code with the exact coordinates above.
The railroad, heard
A whistle at the end of each Stage, the grade-crossing bell at the end of each Day, and the conductor when a train is built. Off by default — everything here is synthesised rather than recorded, so it is a placeholder for real audio and a playtester who did not ask for noise should not get any. One click in the title bar turns it on, and that click doubles as the gesture browsers require before audio may start.
Synthesised, not sampled, and that is a constraint rather than a preference: the site is a static folder that fetches nothing — there is a test asserting no page reaches an external host — so audio would have to be committed to the repo, and a plausible whistle is not something to invent. A real steam whistle is a CHORD of several chambers slightly out of tune with each other plus the breath of the steam, which three detuned partials and a band of filtered noise get most of the way to. The bell is inharmonic partials struck twice, which is what separates a bell from a beep.
"All aboard" is speech, and speech cannot be faked with oscillators. speechSynthesis is built
into the browser, needs no asset and works offline, so it says the words; where the platform has no
voice installed a two-note conductor's call takes its place rather than nothing happening.
The model names WHAT happened — a Stage ended, a Day turned, a train was made up — and the page decides what that sounds like. A Day boundary rings the bell only: both would collide, and the bell is the bigger event. The first Stage of the game announces nothing, because a Stage beginning is the previous one ending and there is no previous one.
Counted against the clock rather than trusted: over a full game, 60 Stage boundaries produced 55 whistles and 5 bells, and 5 Days produced 5 bells.
Regions, drawn from what the crossing already cost
§2.1 divides a Mainline card into two regions and §8.2 moves a train one region per Stage. The
engine had replaced that with crossingStages() — implications.md records it plainly: "The
Region model is gone" — because ten card types with real speeds cannot be expressed by one region
a Stage. Both REGIONS_PER_MAINLINE_CARD = 2 and entryPoints survived as dead constants.
The map draws regions again without reinstating the mechanic, because the printed cards say how: they carry Start positions, so a train with Brakemen enters further along the card. That is the same fact as "takes a Stage off the crossing", in different coordinates. So position falls out of what the engine already knows:
entry = REGIONS - stagesTotal position = clamp(entry + elapsed)
| crossing | drawn as | |
|---|---|---|
| 1 Stage (a 60 card) | enters at region 2, gone next Stage | the printed Start position |
| 2 Stages (a 30 card) | region 1 → region 2 | §8.2 exactly |
| 3 Stages (slow train) | region 1 → region 1 → region 2 | fixed distance, slow train |
entry is deliberately allowed to go negative and only the final position is clamped: that keeps a
slow train's extra Stage at the START, where being slow shows. Clamping the entry instead parked it
at the exit, reading as a train that raced across and then waited — which is what the first version
did, and what the test now forbids.
Transit gains stagesTotal, set on entry. State, not rules: nothing reads it to decide anything,
so the bot and every balance figure are untouched, and a save is a seed plus intents so there is
nothing to migrate. It cannot be recomputed later — a modifier played onto the card mid-crossing
would change the answer and make the train jump backwards.
Division Points draw one region, the queue. Running Track cards inside a district draw none: a crew moves there by Moves, not Stages, so it occupies a card outright.
The Division map shows the whole route
The map drew one box per node, which collapsed each player's entire district into a single "Office" tile — so the track a train actually runs along was invisible on the only view that shows where trains are.
An Office now expands into its Running Track, Limits to Limits. That is the right cut rather than a compromise: the through route IS the Running Track, and everything hanging beneath it is secondary track a crossing train never touches. Sidings, industries and the load pipeline stay in the district view, which is where they can be read.
West DP · Mainline · [Limits … Office … Limits] · Mainline · [ … ] · Mainline · East DP
Trains are shown wherever they are. On a Running Track card, on a Mainline card, queued at a Division Point. A crew working BELOW the Running Track has no position on the through route, so it is reported against the district — "2 switching below" — rather than drawn somewhere it is not. Projecting a siding onto the through line would be a lie the map cannot support.
Seating. Players sit around a table, so the route is laid out the way they do: one row alone, two rows facing, a horseshoe of three, a square of four. The Division is a LINE and not a loop — trains enter at one Division Point and leave at the other — so the shape is deliberately left open, with buffer stops at both ends and the gap labelled "trains enter" and "trains leave". Closing it into a ring would promise a connection the rules do not have.
Layout is geometry with no visual feedback loop, so it is tested rather than eyeballed: for 1, 2, 3 and 4 players no two cells may overlap and none may fall outside the canvas.
The district folds itself away
Auto-focus. The district is worth its vertical space during the phases that change it — Local Operations and Cargo — and not during New Train, Mainline or Supervisor Shift, where the Division map is what matters. Folded, it leaves a summary line rather than vanishing, because a panel that disappears entirely reads as broken.
A manual toggle overrides it and stays put. This is DISPLAY state and never game state: two players at the same table may reasonably want it set differently.
The replay was storing the same things over and over
Halved, near enough: 3415 KB → 1877 KB on a 735-frame game.
The TODO said to carry links forward. Measured first, that would have bought 5% of the cells
payload — cells is 62% of the file, and inside it the cost is elsewhere:
what 32% of cells long prose, identical on every turnout in the district
facility 24% of cells the SAME object already serialised in the frame's `facilities`
identity 26% of cells row/col/kind/label/running/links, fixed once the card is laid
So all three are interned. A card's identity and its description are written once for the whole recording and referenced by integer, and a cell points at its facility instead of carrying a second copy of it. What stays per-frame is what genuinely changes: the cars standing there, the crew, and any Enhancement laid on the card.
rehydrateCells is exported and emitted into the page by toString(), the same trick the two board
renderers use — a second copy inside the page's inline script could drift from the packing and the
symptom would be a board drawing the wrong cards rather than an error. The round-trip test runs that
exact function over every frame, resolving nulls the way the page does.
Rolling stock was leaving the game
Four things were tried against bot revenue. One of them was worth more than everything else in this changelog combined, and it is the one that had been ranked third and predicted not to matter.
The Classification Yard was write-only. Seven places pushed cars into it — retired trains, collisions, unjams, set-outs — and nothing in the engine ever read it. Rolling stock drained one way out of the game: 30 cars dead by the end of a game, 37% of the 80 dealt at setup. Gap 2c says engines and cabooses return to the Division Yard and everything else to Classification; the recovered rules never say how Classification empties.
ASSUMPTION, flagged rather than derived: sorting cars for redistribution is what a classification yard is for, and a Day is its natural cycle, so they now return at the Day boundary. This is a rules decision that wants confirming against the source.
Paired over 400 seeds: +2.32 ± 0.52, t = 8.79, 194 seeds better against 44. Revenue 6.70 → 9.02.
Why it was mis-ranked is worth recording. The Division Yard does not run dry in five Days — 16.6 loaded freight cars remain, empty in 2 games of 100 — so it was reasoned that supply could not be binding. The aggregate was never the point: what starves freight is not having the RIGHT commodity at the moment a green box needs stocking, and returning classified cars keeps the mix alive.
Three things that did not work, kept because the measurement is the result
Measured paired over the same 400 seeds, which is the only way to see effects this size.
Capping the draw option: worth nothing. 62% of all Local Operations actions went to drawing and 12.6 of 29 cards drawn were discarded, so a draw into a full hand converts straight into a discard. Refusing it: -0.10 ± 0.13, and 379 of 400 seeds byte-identical. The branch almost never fires.
Pairing the two halves of a load: worth nothing. Sampling every outbound industry at every loadUnload phase, 76% of the time it had NEITHER a stocked green box NOR a spotted car, and only 5% of Stages had a single workable facility. Letting the bot switch for a stocked box without waiting for a train at the Office changed nothing measurable — folded into the -0.10 above. The diagnosis was right and the prescription did not address it: there is usually nothing to switch.
Waking a dead branch made things worse. chooseLocalOption tested
options.some(i => i.type === 'mainline.modify'), but that intent requires s.turn.option === 'draw'
and the test runs while the option is still null — legalActions had already filtered it out, so the
branch could never fire and never had. Rewriting it to check the hand cost -0.64 ± 0.32
(t = -3.90), 104 seeds worse against 35. Its comment claimed the value compounds like a train
card's; it does not. The branch is now deleted, with the measurement in its place.
The Enhancements other than Interlocking are worth exactly nothing — and cost nothing either:
-0.01 ± 0.41 (t = -0.04) when the bot is forbidden to place any of them. Left as they are; there
is nothing to gain by restricting them. Note the first attempt at this experiment showed 0/400 seeds
changed, which was the experiment failing rather than the answer: a card.play fallback with no
placement filter was still placing them.
| before | after | |
|---|---|---|
| revenue (200 games) | 6.7 | 8.7 |
| wins | 6.5% | 12% |
| freight loads | 2.8 | 3.2 |
| cars dead in the Classification Yard | 28.4 | ~0 |
A Running Track with nowhere to put an Enhancement
Every penalty in the game has one cause. Across 100 games, all 48 were collision: no free A/D track — 2.70 revenue a game, 27% of gross, concentrated in about a fifth of games and
responsible for the −47 tail.
The rules already answer it. Interlocking prints "may stop an inbound train on the Limit Track", and
advance.ts:621 holds the train at the Limits instead of colliding. It had never once been
placed. Nor had any train ever been held at the Limits.
The reason was structural and nothing to do with Interlocking. The bot builds minimal two-arc
run-arounds — an ne arc meets an nw arc directly — so it never needed a straight and laid none:
0.00 straights on the Running Track across 100 games. Interlocking, Water Column and Telegraph
all require one; Yard Office and Small Yard want a Secondary Track Straight; Telephone and Radio
chain off Telegraph. Thirteen of the eighteen Enhancement cards that go on the board were
unplayable. They were drawn 3.78 a game and placed 0.64.
bestTrackLay now scores one straight onto the Running Track. Enhancements placed went 0.64 → 3.01
across nine types where only Overpass had ever appeared, and Interlocking now reaches the board in
about a quarter of games.
On whether it pays — measured properly, and the answer is qualified. Revenue per game has a standard deviation of ~9, so a 100-game run carries roughly ±1.0 of noise. Run against the same 400 seeds, paired:
| without | with | |
|---|---|---|
| revenue | 5.99 | 6.70 |
| collisions cost | 2.70 | 1.91 |
| worst single game | −47 | −24 |
| most collisions in a game | 10 | 6 |
| wins | 5.3% | 6.5% |
Paired per-seed the change is +0.70 ± 0.74 (95% CI), t = 1.87 — not significant on its own. And 154 seeds improved against 165 that got worse: the mean gain comes from removing catastrophes, not from making a typical game better. What justifies keeping it is that the mechanism is measured directly and accounts for the whole effect — collision cost falls 0.79, and revenue rises 0.70.
A correction to the numbers already in this file. The per-100-game revenue figures reported for the earlier changes carry the same ±1.0 noise, so the individual steps (5.0 → 6.0 → 6.5) were stated more precisely than the sample supports. The cumulative move from 3.2 to ~6.7 is far larger than the noise and stands; the individual increments should be read as indicative only.
The bot was throwing away its own freight
Routing turned out not to be the problem, which is why it was worth measuring first. Of 1650 drops across 100 games, 940 (57%) landed on a facility that wanted the car and zero landed on one that did not. The crew makes 9.4 correctly-targeted drops a game; the one-move-only destination test costs nothing measurable. "70% of waiting loads have nothing spotted" was a misleading signal — the cars arrive.
Following the freight from the other end found the leak. Loads were being destroyed:
stockToOutbound 9.45/game
loadStarted 2.71/game
facilityUnjammed 3.10/game from outbound <- healthy waiting loads, discarded
facilityUnjammed from outbound splices the load out of the green box and pushes it to the
classification yard. That load cost a Local Operations action to stock, so discarding it is strictly
negative — and the bot did it more often than it started a load.
Two fallbacks meeting, and the root cause is a familiar one: two rules for one act.
canStockProductively decided the Freight Agent option was worth taking if a matching empty car was
spotted. The engine's stockOutbound additionally requires a loaded car of that commodity in the
Division Yard — which the predicate never checked. So the option was chosen believing a box could
be stocked when none could; the follow-through then found nothing stuck, nothing to clear and
nothing stockable, and fell through to "clear whatever is stuck" with nothing stuck.
Fixed by making the predicate ask the same question the engine does, by no longer using Freight Agent as the idle default (switching at worst moves the crew toward the Office, which a train must reach to depart at all, §8.1), and by ordering the last-resort unjam by what it costs to lose — MEN|AT|WORK first, then the red box whose Revenue is already banked, and the green box last.
| cars fixed | freight kept | |
|---|---|---|
| loads discarded from a green box | 3.10 | 0.00 |
| revenue | 6.0 | 6.5 |
| wins | 5/100 | 8/100 |
| trains scheduled | 3.0 | 3.4 |
| cards played | 16.6 | 19.3 |
The gain is development, not freight. Loads started held at 2.70 and freight revenue at 2.6 — the recovered Local Operations actions went into drawing and switching rather than into the freight chain. Green-box stocking fell 9.45 → 6.34 because the bot no longer stocks boxes it cannot serve. The regression test asserts outbound unjams stay at zero and that genuine MEN|AT|WORK jams are still cleared, so gutting the fallback would not pass it.
Half the industries never asked for a car
Freight had not moved through two rounds of fixing the district, and this is why: three of the six industries were invisible when the bot chose what to put on a train.
wantedCars consulted a hand-written industry -> car type switch that had drifted from the sheet.
It named produceShed and oilRefinery — neither is an industry — and omitted freightHouse,
refinery and packingSheds, which are. An industry it could not name returned null and was
skipped entirely, so it never requested a car. The Refinery is the only source of tank traffic, so
tank cars boarded a train 0.07 times a game and were dropped by a crew zero times in 100 games,
while 23 of 79 waiting loads sat at an industry that wanted one.
It now derives the commodities from INDUSTRY_PROFILES, which is the sheet. The switch is deleted
rather than corrected — a second copy of the mapping is the bug, not the values in it.
A second, narrower collapse. facilityCarType returned carTypes[0], so the second commodity
of a two-commodity industry was unreachable: a Power Plant burns coal OR oil, a Grocer's Warehouse
receives dry goods OR perishables. facilityCarTypes (plural) now returns the full set, and the
bot's spotting and switching checks accept any of them.
Worth stating precisely: the engine's WRONG_CAR_TYPE gate was corrected to use the full set too,
but that changes nothing today — both two-commodity industries are inbound-only, so
freightAgent.stockOutbound rejects them before the commodity is examined. That fix is latent. The
measured gain is entirely the bot side.
| facilities fixed | cars fixed | |
|---|---|---|
| revenue | 5.0 | 6.0 |
| wins | 1/100 | 5/100 |
| freight revenue | 1.3 | 2.6 |
| freight share of gross | 25% | 37% |
| loads completed | 1.27 | 2.60 |
| tank cars dropped | 0.00 | 0.62 |
| reefers dropped | 0.04 | 0.67 |
Both regression tests were checked against the bug they guard: restoring the stale map fails the
tank assertion, and collapsing carTypes to its first entry fails the profile assertion.
Still 70% of waiting loads have nothing spotted at all. The commodity mix is right now; the volume reaching the industry tracks is not. That is a routing question — which facility the crew takes a car to — rather than a car-choice one.
Putting the industries on the siding
The run-arounds were being built and the industries were somewhere else — facilities sitting on one
stayed at 0.00 a game even at 91 districts in 100 with a closed loop. The cause was blunt:
facility placement was options.find(i => i.placement !== undefined), the first legal square the
generator happened to list, unscored, while track laying had sixty lines of scoring beside it.
Facilities are now scored, on the thing that decides whether a crew can serve them at all: a placement in line with a siding still being built becomes part of the loop itself (its own through track is a segment), so the crew reaches it from either end and can pass its standing cars (§A.5). Merely touching reachable track is worth less; the Running Track is a penalty, because a car left standing there is hit by the next arrival (§11.2).
A second bug surfaced immediately, and it is the interesting one. Scoring facilities onto the
siding row sent run-arounds down, 91 games in 100 to 36 — the industry took the square and the
loop stopped closing around it. runsAcross tested the card's KIND ("a track straight running
east-west"), so an industry standing in the line read as a dead end and the run refused to extend
through it. It now asks the card's PORTS instead. A Facility carries its own rails (§11.2), and so
do the Office and a Limits sign; what matters is whether a port faces this way.
The reachability walk is now shared between track laying and facility placement rather than written twice — two copies would eventually disagree about whether a district connects, which is the one thing both decisions rest on.
| before sidings | sidings fixed | facilities fixed | |
|---|---|---|---|
| facilities on a run-around | 0.00 | 0.00 | 1.08 |
| games with a run-around | 0/100 | 91/100 | 71/100 |
| revenue | 3.2 | 4.1 | 5.0 |
| collisions | — | — | 0.4 (was 0.6) |
| track pieces spent | 19.5 | 16.1 | 13.6 |
Run-arounds fall from 91 to 71 because facilities now compete for the siding squares — which is the trade being made deliberately: a loop with an industry on it is worth more than an empty one.
Freight did not follow. Loads completed 1.42 → 1.27 and freight's share of gross 31% → 25%; the revenue gain is passengers and fewer collisions. Of facilities holding a load, 77% still have nothing spotted at all. The industries are now reachable and the right cars still are not arriving — which is the car-selection problem in TODO, untouched by any of this.
The bot was building stubs, not sidings
Measured first: 0 run-arounds in 100 games. A run-around is the engine's own definition of a
useful siding (track.ts) — double-ended, both ends reaching the main, and §A.5's facing-point move
is impossible without one. Every district the bot built was dead-end stubs, 3.86 of them a game,
plus 2.89 cards below the main that reached nothing at all. Before trusting a zero the detector was
handed a run-around built on purpose and found it from both ends.
Three bugs, all the same shape: scoring on local form without checking it reaches anything.
- The +12 rule was commented "close the loop back up to the main: the run-around is complete" and
only tested that a neighbour ran east-west — never that the card above had a south port to join.
The siding terminated in an arc pointing north into empty space. It now requires a way up above,
asked of the engine's
hasPortso the Office counts too;divergesSouthhad looked for a turnout and missed the one way down that is on every board. An arc's facing also decides what it meets —nwjoins west,nejoins 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.
bestTrackLaynever 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 freeunder each section,no limit — trains queueat 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.