Fifteen items from two playtest sessions. Three that read as drawing faults were engine bugs: cars could be added to a train that was not being made up (50 offers in 8 games), the make-up panel merged two trains and could couple a car to the wrong one, and an Office upgrade silently deleted what a Modifier had added. A fourth was a sentinel inside a coordinate's own value range — a Mainline placement travelling as row -1, which is an ordinary district row. Trains are now drawn the way they stand: west on the left, nose toward the way the engine faces, on both the district card and the Division chip. Undo steps back through the game by replaying the save without its last intent. The switching walk keeps its rejections, so the board can say why a square is not offered. Laborers and Porters are on the card, and the rule that a district only grows outwards is finally written down. Versions start here: third digit for fixes, second for a feature set, 1.0 for a release. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GgtkX8JnvKa8y2tuJ8aQf4
1710 lines
107 KiB
Markdown
1710 lines
107 KiB
Markdown
# Changelog
|
||
|
||
Detail behind each commit. Commit messages stay high level; the reasoning, the measurements, and
|
||
the things that turned out to be wrong live here.
|
||
|
||
Measured figures are 100 solitaire Standard games with the developer bot unless stated otherwise.
|
||
The target is 20 Revenue over 5 Days.
|
||
|
||
## Versions
|
||
|
||
Every commit carries a bump, decided with Jesse rather than assumed:
|
||
|
||
- **third digit** — bug fixes to what is already there.
|
||
- **second digit** — a new set of features.
|
||
- **1.0** — the first release we think is solid enough to call one.
|
||
|
||
The number lives in `package.json` and nowhere else; `scripts/build-web.ts` stamps it into every
|
||
page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identified from the page itself.
|
||
|
||
---
|
||
|
||
## Unreleased
|
||
|
||
## 0.1.0 — 2026-08-08
|
||
|
||
The first numbered build. Everything below the "second playtest pass" heading was made under
|
||
`0.0.1`, across twenty commits, which is exactly the problem the version convention above exists to
|
||
fix: "the current build" was only ever answerable by a commit hash.
|
||
|
||
### A third playtest pass — and three of the "display problems" were engine bugs
|
||
|
||
Fifteen items came back from two playtest sessions. Several of the ones that read like drawing
|
||
faults were not: the board was reporting the game accurately and the game was wrong.
|
||
|
||
**Cars could be added to a train that was not being made up.** `newTrain.placeCar` accepted any tray
|
||
with room in its consist, while the New Train Phase's own idea of the train it is waiting on —
|
||
`trainNeedingCars` — requires the tray to be **at a Division Point** and short of its card. So during
|
||
any New Train Phase the Division Yard would hand cars to a train standing on a siding in your own
|
||
district, or one halfway across the Division. Measured before the fix: **50 such offers across 8
|
||
solitaire games**, including Train 9 mid-crossing with three cars already aboard. That is the
|
||
"cars magically appeared on my train" report, and the answer to it.
|
||
|
||
The two questions — "is this the train being assembled?" and "may this car be added?" — now come from
|
||
one predicate (`isBeingMadeUp`, in apply.ts beside `check`), which is what stopped them disagreeing.
|
||
`newTrain.passCar` takes the same guard.
|
||
|
||
**The make-up panel merged two trains into one.** Two trains can be built in the same Stage — a
|
||
timetabled train and a Second Section, or an Extra — and the panel collected every option from every
|
||
tray, titling the result with whichever tray came first out of the map. Reproduced at seed 99,
|
||
Day 3 Stage 12: eighteen car chips under "Making up Train 8", covering two different trains. Worse
|
||
than the wrong caption: the Division Yard chip binds to the FIRST matching option, so clicking a
|
||
hopper could couple it to the other train. The panel is now scoped to the one tray the engine is
|
||
waiting on. This was reported as the history announcing Train 9 while the panel said Train 10.
|
||
|
||
**Upgrading the Office deleted Modifier bonuses.** `officeUpgraded` wrote the new tier's printed
|
||
numbers straight over the facility, so a Restaurant beside a Depot — +1 passenger out, +1 porter —
|
||
was silently erased by the next upgrade, after the card had been spent. The tier is applied as a
|
||
**difference** now, so the upgrade raises the Office by exactly what it is worth and leaves what is
|
||
standing beside it alone.
|
||
|
||
**A sentinel inside a coordinate's own value range.** ABS Signals goes on a Mainline card, and the
|
||
node index travelled as the fake coordinate `{ row: -1, col: node }` — but row −1 is an ordinary
|
||
district row, the first one below the Running Track, where most districts start. So ABS Signals
|
||
highlighted whichever district card sat at that column, and an ordinary Enhancement laid one row down
|
||
was described as being "out on the Mainline, node −2". `card.play` now carries `node` as its own
|
||
field and a Mainline placement has no coordinate at all; the board simply does not light up for it.
|
||
This was the other half of "why is my Depot highlighted?" — the first half being that Enhancements
|
||
legitimately attach to played cards, which nothing on screen said.
|
||
|
||
### The train, drawn the way it stands
|
||
|
||
**`consist` is ordered nose first, and both renderers drew index 0 leftmost whatever the train was
|
||
doing.** A westbound train therefore came out right and an eastbound one came out mirrored —
|
||
reported from seed 270861860, Train 10 running east and drawn engine-first at the WEST end, which
|
||
reads as an engine shoving its whole train ahead of it. The board is a map, so the drawing obeys the
|
||
map: west on the left, nose toward the way the engine faces. A crew on a north-south spur keeps
|
||
nose-left and lets its ▲/▼ say the rest.
|
||
|
||
**The Division chip draws the train now.** It was a name and a figure, and the figure was Stages left
|
||
to cross — read once as the car count, and once it was labelled `· 2⧗`, read as redundant beside the
|
||
position the card already draws. It is: the region position is DERIVED from that countdown. So the
|
||
chip carries the consist instead, in the same vocabulary the district card uses, and the countdown
|
||
moved into the tooltip where there is room to say what it is. Stages are not regions — a card is two
|
||
regions of fixed distance, and how many Stages a train needs over them depends on the card speed and
|
||
the train (`crossingStages`); they coincide only for a 30 card and a normal train.
|
||
|
||
**Loaded or empty, told the same way in both places.** An occupied slot on a district card took a
|
||
generic blue fill and only a LOADED car overrode it, so at 26×15px empty read as blue-ish and loaded
|
||
as brown-ish — while the tray beside it made the same distinction unmistakable. Reported as "I left a
|
||
car in a siding and now I can't remember if it was loaded". The card now matches the tray, and every
|
||
slot and every car in a consist carries its own words as a tooltip.
|
||
|
||
### Saying what the game already knew
|
||
|
||
**Why a switching move is not offered.** The switching game was played off a list of coordinates —
|
||
`move to (0, -2)` as a button, nothing on the board, and no account at all of the squares missing
|
||
from the list. `exploreMoves` returns the walk's REJECTIONS alongside its destinations, out of the
|
||
same traversal, so a reason on screen is the rule that actually refused the square rather than a
|
||
second guess at it. Five conditions: the rails do not meet, another train is on the card, the
|
||
industry is locked by MEN AT WORK (§9.3 — no train may enter it *or cross it*), the coupling would
|
||
overfill the consist, or it is a turnout you may run through but not stop on.
|
||
|
||
The board now shows where the crew is, where it may go, and marks the rest with its reason on the
|
||
card's own tooltip. A turnout is drawn differently from an obstruction, because it is not one — and
|
||
it is kept out of the Blocked panel, where every turnout in the district would otherwise appear.
|
||
|
||
**Mandatory coupling is named on the button.** "Move to (0, -2)" becomes "move to (0, -2) — couples
|
||
loaded hopper, caboose on the way (onto the nose)". Coupling is compulsory (§A.4) and was announced
|
||
only in the history panel, which is the one place a player is not looking while switching. 70 move
|
||
buttons in five games now say what they will pick up.
|
||
|
||
**The workers are on the card.** "How do I see the number of laborers in an industry card?" — you
|
||
could not; the number that decides every Cargo phase was in a side panel. Porters were worse:
|
||
`porters` had been on the view-model all along and **no renderer had ever drawn it**, which is why a
|
||
Restaurant's +1 porter had "no indication anywhere". Both are on the card now as free-over-total, and
|
||
the Facilities panel shows porters beside laborers.
|
||
|
||
**Ships-out and receives, in words.** A Refinery and a Grocer's Warehouse were distinguished by the
|
||
stroke colour of one 13×12px box and the direction of a 10px chevron. Rendered both and diffed the
|
||
SVG to check the complaint: identical shape, identical sign, same box count in the same place. The
|
||
card says `SHIPS OUT` or `RECEIVES` now.
|
||
|
||
**And the Office told the truth about itself.** `baseOf` returned zeros for a passenger facility
|
||
despite a comment claiming it read the Office tier, so a plain Depot displayed `out 1 +1` — crediting
|
||
a Modifier that was never played. The card tooltip had the mirror-image bug, reading the printed tier
|
||
rather than the Office as it stands, so a Modifier's effect never showed there either.
|
||
|
||
### Undo, and the chrome around it
|
||
|
||
**Undo, as far back as you like.** The save is the seed plus the intents, so undo is a replay without
|
||
the last one: no undo stack, no inverse of each action, and no way to reach a position the rules could
|
||
not have produced. Solitaire only. It is a deliberate take-back rather than a rewind — the RNG
|
||
advances with the replay, so a train card re-rolls the same Stage, but you can see that roll and then
|
||
spend the turn differently. `TODO.md` carries the question of whether a Stage boundary should become
|
||
a commit point.
|
||
|
||
**Both Division end labels were clipped.** They hung off the outside of the end cells and needed
|
||
padding wider than the caption to survive, which the board did not have — and giving it to them would
|
||
have spent that width on two captions instead of on the map. Centred under their own Division Point
|
||
they cost nothing, and `PAD` drops from 92 to 22.
|
||
|
||
**The turn chart scrolls away no more.** The title bar was sticky and the row under it — Day, Stage,
|
||
phase, waiting-on — was not, so scrolling the board took away the thing most worth glancing at. They
|
||
are one sticky block now.
|
||
|
||
**The timetable has a key.** Blue is a train due, violet is the Stage you are in, dimmed is gone, and
|
||
the green is a *flash* marking the slot the die just filled — which is exactly why it needed saying.
|
||
|
||
**And the rule that shapes every district is finally written down.** A district only grows outwards:
|
||
the Limits signs are the growth point on the Running Track and move outward with the card, and no
|
||
card anywhere can be inserted between two cards already down. Enforced since the beginning, stated
|
||
nowhere. It now sits beside the district, and a legal square that already carries a card says what
|
||
playing there would do — `EXTEND THE RUNNING TRACK HERE`, `ATTACH TO THIS CARD` — instead of lighting
|
||
up blue and saying nothing.
|
||
|
||
### Measurements
|
||
|
||
The two engine fixes take things away, so both were measured rather than assumed: 200 paired games
|
||
before and after come to **revenue 1.4 either way**, cards played 24.4 → 24.2. Neither was paying for
|
||
anything the bot relied on.
|
||
|
||
Worth stating plainly, since the changelog above still carries older numbers: the developer bot now
|
||
averages **1.4 revenue against a target of 20**, with 1 win in 200. That is not a regression from this
|
||
work — it is the same at `f4c0f49` — and it is consistent with what `TODO.md` already records about
|
||
barring curves from the Running Track and making industries stub-only. The bot has not been retaught
|
||
since those rules tightened.
|
||
|
||
Tests 386 → 402.
|
||
|
||
### A second playtest pass
|
||
|
||
**The arrival message told you to do work you could not do.** Train 4 is the *Express*, which carries
|
||
`expedite: true` — Q3 departs an expedited train in the SAME Stage it arrives. So it correctly
|
||
arrived and highballed in one Mainline Phase and correctly did not wait at the Depot. But
|
||
`trainArrived` narrated *"it will highball again next Mainline Phase, so any work must happen now"*
|
||
to every arrival, which for an expedited train is exactly backwards, and the log then contradicted
|
||
itself two lines later. The event carries `expedited` now and the message says which case it is.
|
||
|
||
**`TX14 (2)` was Stages remaining to cross that Mainline card**, not the car count — a bare figure
|
||
beside a train's name, read as the consist twice by the same player because that is the obvious
|
||
guess. Now `TX14 · 2⧗`.
|
||
|
||
**"stock a coach at (0, 0)"** reads as putting a car on the track, and was reported as exactly that
|
||
confusion: no train at the Depot, so how is a coach being stocked there? It is a load taken from the
|
||
Division Yard into the green Loading box, waiting for a train that can carry it — for a passenger
|
||
facility, people on the platform. It now says so.
|
||
|
||
**"trains enter" / "trains leave"** claimed the Division ran one way. Odd trains run west and even
|
||
run east, so both Division Points are a way on AND a way off; the buffer stops only mean it is a line
|
||
rather than a loop. Both ends now read `in and out`.
|
||
|
||
**The discard targets were "a very tiny highlight".** Choosing a card to PLAY lights big ghost squares
|
||
on the board; choosing one to discard lit a 2px outline on a small tile in a panel you might not be
|
||
looking at. The piles now grow, brighten, say DISCARD HERE, pulse once, and dim the Salvage Yard
|
||
beside them so the three that are choices read as a choice.
|
||
|
||
### The timetable, and watching the die land
|
||
|
||
Playing a train card rolls 1D12 for the Stage it departs, and the card simply left the hand — the
|
||
answer arrived as one line in the history panel, among many. The twelve slots have been in the Frame
|
||
all along, and only the standalone replay ever drew them.
|
||
|
||
The play page now has a **Timetable panel**: twelve Stages across, the train due out at each, the
|
||
current Stage lit, Stages already gone dimmed. Playing a train card **flashes the slot the die just
|
||
filled**, sounds a die-and-chime cue, and says it in words above the actions — *"Train 4 is scheduled
|
||
to depart at Stage 11 — see the Timetable"*. The flash marks the moment rather than the state: it is
|
||
gone by the next render, which is what makes it read as "that just happened".
|
||
|
||
No acknowledge-click. It would stop the game to say something the highlighted timetable says better,
|
||
and would be clicked through by the third time.
|
||
|
||
### The load pipeline is drawn the way freight actually flows
|
||
|
||
Reported after unloading at a warehouse: *"it went W, A, M, and then to the red box at the far right."*
|
||
|
||
**The engine was right.** §9.3 is explicit — *"The first Laborer replaces the load with an empty car
|
||
of that type on the industry's track and places the load on WORK. Additional Laborers move it to AT
|
||
then MEN. The last one places the load on a red Unloading box."* An unload ends in red, and it did.
|
||
|
||
**The drawing was wrong.** The pipeline was laid out `green → MEN|AT|WORK → red`, left to right, with
|
||
both arrows pointing right. But loading runs Green → MEN → AT → WORK → **car**, and unloading runs the
|
||
other way entirely: **car** → WORK → AT → MEN → red. Green and red therefore both belong beside MEN,
|
||
and the car belongs beside WORK — so putting red at the far right put it exactly where the car is,
|
||
and an unload appeared to run backwards across the whole row and land on the end it had just left.
|
||
|
||
Now green and red both sit before the sign with their arrows pointing opposite ways, and **only the
|
||
boxes an industry actually uses are drawn**: a Grocer's Warehouse has no green box and a Mine Tipple
|
||
no red one, where before every industry showed both and half of them were dead squares.
|
||
|
||
The Facilities panel had the same left-to-right assumption in its row labels. `green` and `red` are
|
||
now `waiting to load` and `cleared inbound`, each with the direction its loads travel spelled out,
|
||
and a row an industry cannot use is omitted rather than shown empty.
|
||
|
||
### A playtest report, worked through
|
||
|
||
**Backing up turned the train around.** `facing` is which way the ENGINE points, and it was reset to
|
||
the direction of travel on every move — so one reverse move silently spun the train about, everything
|
||
read "forward" again, and a run-around became pointless: you could change ends for free by backing up
|
||
twice. Running forward the engine leads and points the way it went; backing up it trails, still
|
||
pointing the way it came, which is the port it arrived through. Both hold around a curve.
|
||
|
||
**"drop 1 car(s)" hid an option entirely.** It never said which car, and it read identically for a
|
||
nose drop and a tail drop — so the action list's duplicate-label filter discarded one outright, and
|
||
setting out from the front of the train could not be chosen at all. The same failure as the turnout
|
||
rotation earlier, in a feature added two commits ago to fix the make-up deadlock. Now: *"set out the
|
||
caboose off the back"*.
|
||
|
||
**ABS Signals offered the Office Area.** The card says "any Mainline card", and
|
||
`checkEnhancementPlacement` reads `placement.col` as a Division NODE index for it — while the
|
||
candidate list handed it occupied grid cells. So "(0, 1)" was accepted because node 1 happened to be
|
||
a Mainline card: the label and the meaning were different things. It now offers the Mainline cards by
|
||
name, *"on the Uncontrolled Siding, out on the Mainline"*.
|
||
|
||
**Two T10 chips on one Office.** A train standing at the Office is on the Office grid card AND on an
|
||
A/D track, so it arrived in both lists and was drawn twice.
|
||
|
||
**The train was invisible on the board**, which is what made the switching game unplayable: every
|
||
decision is about car ORDER — which car comes off next, which end a cut couples onto — and the card
|
||
showed a name badge. The train is now drawn as it sits in the tray: engine in its place with an arrow
|
||
for which way it points, cars in order, loaded solid and empty hollow. That arrow is also what makes
|
||
"(reverse)" mean something.
|
||
|
||
**Modifier effects were applied and invisible.** The Ice House and Local Small Groceries both worked
|
||
— `capacity.outbound` and `laborers` went up — but the card draws `Math.max(1, greenCap)` green
|
||
boxes, so 0 → 1 looked identical, and laborers were never drawn at all. The Facilities panel now
|
||
shows laborers, outbound and inbound as numbers with what a Modifier added (`2 +1`), names the
|
||
Modifier cards beside the industry, and says in the tooltip what the card itself prints. Laborers had
|
||
been in a hover tooltip only, which is the wrong place for the number that decides every Cargo phase.
|
||
|
||
**Coupling was silent and left no trace.** A car simply vanished from the board with only a line of
|
||
history to say where it went. There are now two new sounds — a knuckle-coupler clank for coupling and
|
||
a quieter one for setting out — alongside the whistle, bell and conductor.
|
||
|
||
**Moves left were reported only in the history panel**, which is the one place a player is not
|
||
looking while switching. Now beside the buttons, and struck red at zero.
|
||
|
||
**A/D tracks were a tooltip.** "3 A/D tracks" with nothing on the card — the number that decides
|
||
whether the next arrival is an automatic collision. Drawn as pips, filled for taken; there is no room
|
||
on the card for more rails and the count is what matters.
|
||
|
||
**The phase changed under you.** Local Operations ends the moment the last Move is spent and the
|
||
automatic phases then run themselves, so the page could change between two clicks with no notice. A
|
||
banner now names the phase it moved to.
|
||
|
||
#### What was already right, from the same report
|
||
|
||
The car type and colour on the board (read as "cab in red" without being told), the Blocked panel
|
||
explaining a full industry track, the Facilities panel's accepted car types and spotted cars, and the
|
||
Division chip's `T10 (2)` with its consist on hover. All four were reported as useful, and none of
|
||
them changed.
|
||
|
||
### Softlock: a train being made up with no visible way to make it up
|
||
|
||
Reported at Stage 10 of seed 775569289 — Train 10 at the West Division Point, history saying "now
|
||
taking cars", and **nothing at all under Your Move**.
|
||
|
||
Moving train make-up onto the Division Yard chips took the "Making up …" group out of the action
|
||
list, and everything that was not a car went with it: the heading naming the train and what its card
|
||
calls for, and the "no more cars" button. The nine cars the train could take WERE clickable on the
|
||
yard chips the whole time — nothing on screen said so, and the panel a player looks at was empty.
|
||
|
||
The panel is back, and now says where to click: *"Click a car in the Division Yard below to add it —
|
||
9 kinds it may take are highlighted there."* It carries the pass button when passing is legal, which
|
||
§7 allows only when the Division Yard is bare — "must make every effort to find a suitable car".
|
||
|
||
**The engine was never at fault.** 2065 New Train decisions across 60 campaign games, and not one
|
||
offered zero options; the pass/place pair covers the phase. The panel still words the third case
|
||
honestly rather than implying a button that is not there.
|
||
|
||
**Two guards, because the obvious one would not have caught it.** A menu-level invariant — every
|
||
option the engine offers must be reachable through something the menu exposes — passes on this bug,
|
||
because `makeUp.pass` was in the menu and correct all along. What failed was the page never reading
|
||
it. So there is also a coarse check that `main.ts` references every field the menu offers: a field
|
||
nothing reads is either dead or a control that has gone missing. Verified by putting the regression
|
||
back and watching it fail.
|
||
|
||
### Four reports from playing seed 775569289
|
||
|
||
**A curve was described as a turnout.** Both read "east-west track with a 45° leg", which is a
|
||
turnout — a road straight across the card plus a leg off it. A curve has ONE road: in from the east
|
||
or west edge, along the centre line to the frog, out at 45° through the middle of a north or south
|
||
edge, and **nothing runs past it**. Which is precisely why it may not be laid in the Running Track,
|
||
so describing it as though it had a through track contradicted the rule that stops you.
|
||
|
||
**A curve in hand showed no preview.** The shapes were read off the card's legal PLACEMENTS, so a
|
||
card with nowhere legal to go had nothing to draw — and that is exactly when a player most wants to
|
||
see what the piece is. They now come from the card itself, which is where they belong: what a piece
|
||
looks like does not depend on whether there is currently a square for it.
|
||
|
||
**"Realignment on Mainline card 3"** named a raw node index. It said nothing about which stretch of
|
||
the Division it meant or what it would do, and it had no tooltip either — because the action list
|
||
attaches one only when a label happens to contain an em-dash, which this one did not. It now reads
|
||
|
||
> Realignment on the Uncontrolled Siding — the second Mainline card west to east; converts it to
|
||
> Double Track
|
||
|
||
and any action naming a card falls back to that card's own description when its label carries no
|
||
explanation of its own. That was the actual complaint: the same card explained itself perfectly in
|
||
hand and said nothing in the action list.
|
||
|
||
**It offered only one Mainline card, and that was correct.** `REALIGNMENTS` converts Plains, Curves,
|
||
Uncontrolled Siding and Trestle; the Division on that seed is a Heavy Grade and an Uncontrolled
|
||
Siding, so only the second could be converted. The engine was right and the label was hiding it —
|
||
"Mainline card 3" gave no way to tell a considered restriction from a bug. Naming the card fixes the
|
||
report without changing the rule.
|
||
|
||
### The hand is the action surface, and the yard makes up the train
|
||
|
||
The action list reached **22 buttons**, and most of it was a cross-product. A card appeared in two
|
||
panels under two different models: as a *subject* under "Play a card from my hand", which then
|
||
highlighted squares on the board, and as one flat button per Department under "Discard a card from
|
||
my hand". Four cards times three Departments was **twelve buttons repeating the same three choices
|
||
four times**, about 290px of the list. Separately, making up a train offered up to ten buttons
|
||
reading "add loaded hopper", "add empty boxcar" — while the Division Yard sat on screen already
|
||
showing exactly those cars by type and load state.
|
||
|
||
Both are now on the objects already being looked at, using the pattern board placement always had:
|
||
**pick the thing, then pick where it goes.**
|
||
|
||
- **Every card in hand carries its own verbs.** `play` highlights the squares it may go on, exactly
|
||
as before; a card needing no square (an Office upgrade, a train, a maneuver) goes down in one
|
||
click. `discard` lights up the three Department piles as targets — they already show their top
|
||
card and their depth, which is precisely what you choose between.
|
||
- **A make-up car is picked off the Division Yard chip** that shows it. The loaded and empty counts
|
||
are separate targets, because a car of a type and a load state is exactly what the choice is.
|
||
- The action list keeps what is not about a card or a car: the Local Operations choice, drawing,
|
||
switching moves, the Freight Agent, and finishing.
|
||
|
||
**Measured over a full game of seed 430: the widest action list went from 22 buttons to 5.** A test
|
||
now walks the same game and fails if it climbs back above 8.
|
||
|
||
The rotation step stays where it was and is now the only thing the placement panel shows — the card
|
||
is picked in the hand and the square on the board, so a rotation is the one question neither of those
|
||
can ask. Its hover previews, added earlier, are unchanged.
|
||
|
||
### A note on the two replay viewers
|
||
|
||
`TODO.md` now carries an item to decide between them. The standalone `node src/sim/replay.ts` writes
|
||
a self-contained HTML file that nothing links to and that `.gitignore` excludes; the site reads JSON
|
||
saves from `public/replays/`. The standalone one carries the bot's decision trace and a timetable
|
||
panel, which is debugging material rather than something a player wants. No action taken.
|
||
|
||
### A replay now looks like the game it is a replay of
|
||
|
||
**"Extra slow" was there and did nothing.** The site's replay viewer builds its interval with
|
||
whatever the speed select held when play started, and nothing re-read it — so changing pace
|
||
mid-replay had no effect at all and the pace looked stuck. The standalone replay had always
|
||
restarted its timer on change; this viewer was missed. Both offer the same five paces, extra slow
|
||
through very fast, and a test now asserts they stay in step.
|
||
|
||
**The turn chart lived on one screen out of three.** Where you are in the Day — the five phases with
|
||
the violet "you are here" — was in `main.ts` alone, so both replays reported the Day and the phase as
|
||
two plain strings. The same position looked like a different game depending on which screen you were
|
||
on. It is now `sim/turnchart.ts`, shared exactly as the board renderers are: the playable page and
|
||
the site viewer import it, and the standalone replay embeds it by `Function.toString()` because it
|
||
is a single file with an inline script and cannot import anything.
|
||
|
||
**And the side panels were three against eight.** The viewer showed the Division, the Office Area and
|
||
the log; the play page shows those plus cards in hand, the Department decks, the yards, the blockers
|
||
and the facilities. A replay could not answer *"why is nothing moving?"* — which is most of what a
|
||
replay is for. `web/panels.ts` now renders all of them for both pages, and the duplicated CSS is gone
|
||
from `play.html`.
|
||
|
||
Three tests hold it there: both screens must call the same panel renderers, all three must use the
|
||
shared turn chart and none may keep a private copy of the phase table, and the two viewers must offer
|
||
the same paces.
|
||
|
||
### You could not rotate a turnout at all, and now you can see what you are laying
|
||
|
||
**The rotation was being thrown away before it reached the menu.** `actionGroups` drops duplicate
|
||
labels, and `describeIntent` for a card play said only `play right-hand turnout at (0, 1)` — no
|
||
rotation in it. So a turnout's two orientations produced the same label and the second was silently
|
||
discarded. The "choose a rotation" step existed and worked; it was never given more than one
|
||
rotation to choose between. The label now names the orientation, and both survive.
|
||
|
||
**And the buttons are pictures now.** Hovering a rotation draws the piece as it will land on the
|
||
board, and hovering the card itself draws every shape it could be laid as — which is how a player
|
||
sees a turnout has two orientations before picking a square at all. Rendered by `officeSvg`, the
|
||
board's own renderer, on a one-card board: the preview and the board cannot disagree about what the
|
||
piece looks like, and the rails come from the engine's `connectionsFor`, so a preview cannot promise
|
||
a shape the placement will not produce.
|
||
|
||
`Placeable.spots` carries the links for this. `data-tip-html` on the tooltip renders a figure above
|
||
the caption; it is only ever set from markup this app builds.
|
||
|
||
### Turnouts say what they do, and a curve may not break the Running Track
|
||
|
||
**"Right-hand turnout, stem east, through west, diverges north at 45°"** is three pieces of jargon
|
||
and a compass reading, and none of it answers the only question being asked: *if my train comes in
|
||
from over there, where can it go?* Turnouts now read
|
||
|
||
> allows traffic from the east to travel west or turn to the south
|
||
|
||
and curves
|
||
|
||
> carries traffic from the west round to the south
|
||
|
||
everywhere they appear — on the card in hand, on the placement it would make, and on the card once it
|
||
is down. §A.1's rule falls out of the same sentence rather than needing a shouty clause of its own:
|
||
a train coming the other way, from the through end or the diverging one, may only leave by the stem,
|
||
so the two roads never join.
|
||
|
||
**A curve laid in the Running Track dead-ends the main.** A curve has ONE road — from an east or west
|
||
edge round to its 45° leg — so a card of it standing in the running row stops the through route at
|
||
that square and cuts the Office off from its own Limits. Nothing forbade it, and the bot did it: an
|
||
early trace shows it laying a `ne` curve at (0,−1), turning the west end of its own Running Track
|
||
into a stub. Every card that may stand in that row now has to carry the road across it —
|
||
`carriesThroughTrack` — which straights, turnouts, Limits signs, the Office and industries all do.
|
||
Reported as `BREAKS_RUNNING_TRACK` rather than `NOT_CONNECTED`, because it is a different mistake:
|
||
the card would join perfectly well and would still leave the main stopping dead at it.
|
||
|
||
**The crossover is confirmed, both hands.** A turnout that *"allows traffic from the east to travel
|
||
west or turn to the south"* joins the one directly beneath it that *"allows traffic from the west to
|
||
travel east or turn to the north"* — the two 45° legs are one continuous rail across the card edge.
|
||
It already worked; it now has a test that says so in those words, along with its mirror built from
|
||
the other hand, the mismatched pair that must NOT join, and a crew actually running down through it
|
||
onto the parallel track.
|
||
|
||
**Measured, and worth flagging:** barring curves from the running row cost the bot a good deal —
|
||
districts 28.0 → 19.7 cards, facilities 2.23 → 1.85, revenue about 2.0 → 0.8. The rule is right and
|
||
the bot was partly living off an illegal placement. Two tests needed widening rather than weakening
|
||
as a result: the unload regression sampled three deals and now samples twelve (unloads still happen,
|
||
34 across a 40-deal sweep, just not on those three), and the canvas bounds test now holds a straight
|
||
as well as a curve, since a curve alone is offered only one square once the running row is closed to
|
||
it.
|
||
|
||
### The Crew Tray is a train, and a train must be made up to leave
|
||
|
||
**§8.2 was not checked at all.** A train could highball onto the Mainline engine-last with its
|
||
caboose in the middle. Now a train held at the Office is held until it is made up: the engine at an
|
||
end of the tray — pulling or pushing, both are real — and the caboose at the far end from it. The
|
||
check is deliberately direction-free, because what a train may not be is *broken-backed*, with the
|
||
engine buried among its own cars and some ahead of it and some behind. That state is only reachable
|
||
through switching: a train arrives made up and comes apart because the player took a cut onto the
|
||
nose or picked cars up in a run-around.
|
||
|
||
**`engineAt` was written and never maintained.** Cars taken onto the nose go AHEAD of the engine —
|
||
Appendix A: *"a train can pick up two cars and add them to the Crew Tray in order that they were in,
|
||
pushing them into the Facility"* — so the engine stops leading, and its recorded index did not
|
||
follow. It does now, and drops adjust it the other way.
|
||
|
||
**Setting out from the nose was missing entirely.** Appendix A uses the move in its own worked
|
||
example — *"Back up and drop off everything on the nose of your train (red and blue) on Card B"* —
|
||
and `switch.dropCars` could only ever take from the tail. Without it, cars taken onto the nose could
|
||
never come off, so an engine buried in its own train had **no way back to an end**: the first version
|
||
of the make-up rule stranded a train permanently in 6 games of 40, holding an A/D track for the rest
|
||
of the game. `fromNose` fixes it, and a cut is now guarded to come off an OUTER end only — lifting
|
||
cars from beside the engine would leave the far end of the train coupled to nothing.
|
||
|
||
The bot learned both remedies: dig the engine out when it is buried, and shed a misplaced caboose
|
||
when it is at a reachable end. Measured over 40 games: **106 make-up holds across 6 games → 13 across
|
||
1**, revenue 1.55 → 2.05. The one that remains is a caboose stuck mid-train, which genuinely needs a
|
||
run-around or a Small Yard — the game working, not a defect.
|
||
|
||
**A knock-on worth recording:** with cars now set out properly, the freight pipeline runs clean — 55
|
||
loads started and 54 completed across the 40-game sweep, with **zero jams of either kind**. The
|
||
regression test that asserted the bot clears a MEN|AT|WORK jam had become untestable, so §6.3's
|
||
unjam is now asserted directly against a constructed jam instead of hoping the bot stumbles into one.
|
||
It also checks that clearing the jam reopens the track, which is the point of it.
|
||
|
||
**You can see which car is where.** The board printed the first three characters of each car's
|
||
label, which for "loaded hopper" and "loaded boxcar" alike is `loa` — every car on the map looked
|
||
identical. The switching game is entirely about getting the RIGHT car to the right industry, so type
|
||
now reads by colour and by three letters (`box` `hop` `tnk` `rfr` `cch` `cab`), and loaded shows as a
|
||
filled slot against an empty one's outline — the same distinction the printed game makes with
|
||
coloured tokens.
|
||
|
||
### Operational Rail — a locked industry could be driven straight over
|
||
|
||
Checked the whole rule against Appendix A of `StationMasterPrototypeRules.pdf`, which defines it
|
||
outright: *"Operational Rail is any track card that a train can stop and leave Rolling Stock
|
||
(uncouple) on. Operational Rail is any track card with a train wheel icon on it."*
|
||
|
||
Most of it was already right, and the wheel icons in the rules diagrams confirm which cards carry
|
||
one. Straights, curves, industries and the Limits cards all do. The turnout does not, and the page
|
||
says so in words — *"Since there is no Operational Rail wheel icon, the train may not stop on this
|
||
card"* — so a train runs through one and may not stop or uncouple on it. Both already held.
|
||
|
||
**The Office is Operational Rail.** The Depot card in the diagrams carries a wheel icon and is drawn
|
||
as one of the green squares a Crew Tray may move to, and the Special Rules say *"While your Office
|
||
Track is considered Operational Rail, Rolling Stock may not be dropped off here."* So a train may
|
||
stop there and may not leave cars — which is what `canDropCarsAt` already did. Worth stating plainly
|
||
because it is easy to read the "no cars here" half as "not Operational Rail", and the A/D track
|
||
mechanic depends on trains being able to hold at the Office.
|
||
|
||
**The real gap was §9.3's lockout.** *"While ANY loads are in the MEN | AT | WORK track, the
|
||
industry's track is locked down... It loses its status as Operational Rail. No cars can be picked up
|
||
or dropped off, and no trains may occupy **or move on** it."* Losing Operational Rail status only
|
||
stops a train FINISHING somewhere — a turnout is not Operational Rail either and trains run through
|
||
one all day. Passage was never blocked, so a crew rolled straight over a locked industry and, because
|
||
coupling is automatic and mandatory, picked up the cars spotted on it on the way past. Those are
|
||
precisely the two things the safety lockout exists to prevent.
|
||
|
||
`isLockedByWork` is now separate from `isOperationalRail` for that reason: "cannot stop here" and
|
||
"cannot pass through here" are different properties and only a locked industry has both.
|
||
|
||
Covered by a new suite in `track.test.ts` that walks each card type against the wheel-icon rule,
|
||
asserts the Office may be stopped at but not unloaded on, asserts a turnout may be passed but not
|
||
stopped on, and asserts a locked industry blocks both stopping and passage — then reopens when the
|
||
work clears. It also checks the supply catalogue's `isOperationalRail` column against the rule, so
|
||
the data and the diagrams cannot drift apart.
|
||
|
||
### Industry cards: on a stub, one of a kind, and never both ends of a chain
|
||
|
||
From the sheet, the "Placed" column reading identically for all six industries — **"Straight, Stub
|
||
(not on Running Track)"** — and the "Lockouts" column beside it.
|
||
|
||
**An industry may no longer be built on the Running Track.** It was allowed and merely *warned*
|
||
about: the card text noted that a car left standing there would be hit by the next arrival. That is
|
||
a hazard, not a rule, and it let a player skip the district entirely and spot cars on the main line —
|
||
which removes the whole switching puzzle, since the point of a siding is getting a car down off the
|
||
main and back. `check` now returns `ON_RUNNING_TRACK`.
|
||
|
||
**No two of the same industry in one Office Area.** The sheet states this in the Freight House row,
|
||
which lists Freight House among its own lockouts; the catalogue had dropped that self-reference as if
|
||
it were a typo. It is a general rule, so it is enforced for every kind in `isLockedOut` rather than
|
||
repeated in all six entries.
|
||
|
||
**Producer and consumer of the same commodity stay apart.** Mine Tipple makes the coal a Power Plant
|
||
burns; the Refinery makes the oil it also burns; Packing Sheds fill the reefers a Grocer's Warehouse
|
||
empties. Build one end of a chain or the other, never both — which is what pushes freight to run
|
||
between districts instead of circling inside one. The pairs were already right; they had **no test
|
||
coverage at all**, and now have a suite that checks each pair in both directions, checks that
|
||
industries sharing no commodity may stand together, and checks the catalogue against the sheet
|
||
column so a change to it has to be deliberate.
|
||
|
||
**Measured, 60 solitaire Standard games:** 0 industries on the Running Track, 0 duplicates. Industry
|
||
placements fell from 3.84 a game to 2.23 and revenue from 2.87 to 1.35, because the bot builds
|
||
shallow districts and there are now far fewer legal squares. Not rebalanced — deliberately. The
|
||
counts, the industries and the track mix are all due a pass together once the rules are right.
|
||
|
||
One consequence worth naming: `flyingSwitch` stopped firing in the 60-game reachability sweep. The
|
||
rule is fine — `mainline-cards.test.ts` exercises it end to end on a hand-built siding — but the bot
|
||
no longer gets a crew next to an industry. It is exempted **by name** in that test, with the reason
|
||
written next to it, so the other forty-odd event checks stay live and deleting the line is what
|
||
proves the bot has been fixed.
|
||
|
||
### §6.2's reshuffle, and the Departments and Salvage Yard on screen
|
||
|
||
**The reshuffle existed as a fiction.** `events.ts` declared `{ type: 'deckReshuffled' }` and
|
||
`narrate.ts` had a line of prose ready for it — "Home Office deck ran out — Salvage Yard reshuffled
|
||
back in" — and nothing anywhere emitted or reduced it. `check` just returned `DECK_EMPTY`. A declared
|
||
event with narration written for it reads as an implemented feature to anyone grepping for one, which
|
||
is worse than an obvious gap.
|
||
|
||
Now real: when a draw takes the last card, the Salvage Yard and all three Department decks are
|
||
collected, reshuffled, and §4.6-4.7's opening is re-run — three cards turned face up as the
|
||
Departments, the rest face down as the deck. Cards played onto the board are **not** recovered; they
|
||
are on the table, which is where they belong. A game that has genuinely used everything still ends on
|
||
`DECK_EMPTY` rather than reshuffling an empty sweep.
|
||
|
||
The full shuffled order rides the event rather than being recomputed from `rngState`. A save is a
|
||
seed plus the intents, so events are never serialised and the size costs nothing — and an event that
|
||
states the outcome outright cannot drift from the reducer the way a re-derivation can. Covered by a
|
||
test that builds the same position twice and asserts the two decks come out identical.
|
||
|
||
**It has not fired in play yet, which is worth knowing.** Solitaire Short/Standard/Campaign end with
|
||
186.8 / 176.2 / 168.8 cards left of 243 and never ran dry across 60 games each; four-player Campaign
|
||
ends with 86.6 across 25 games. It is a safety net, not a live mechanic. It is not the case that the Departments
|
||
only grow — a player takes the top card of one as their draw as readily as discarding onto it, so a
|
||
Department can be drawn down and refilled from the Home Office deck. What the reshuffle guards is the
|
||
Home Office deck itself running out, which is possible whichever way the piles happen to be moving.
|
||
|
||
**The Salvage Yard is a pile like the others**, and now shown like them. It was already modelled as a
|
||
list; what it lacked was any presence on screen, which mattered the moment it became the thing that
|
||
comes back in a reshuffle. Watching it fill is the only warning a player gets that the deck is about
|
||
to turn over.
|
||
|
||
**All four piles now show their top card and their depth.** Each is drawn as a card with the pile's
|
||
name and a count badge on it, then the face-up card underneath. The depth is a count and not a hint:
|
||
only the top card may ever be drawn, so everything below it is out of reach, and choosing where to
|
||
discard is choosing what to put there.
|
||
|
||
### The Departments are decks, and you choose which one to discard onto
|
||
|
||
From the designer: *"when discarding from their hand, the player can select which department card
|
||
deck they want to place the discard on top of. Department card decks are shared across all players.
|
||
When pulling from a department card deck players may only pull the top card."*
|
||
|
||
The rules already said so and the code had read them the other way. §6.2: a discard is placed "face
|
||
up **on top of** one of the three Department slots", and a draw takes "the **top** face-up card".
|
||
Both phrases only mean something over a pile. Gap 4a had concluded the Departments were "three
|
||
face-up market slots fed from the one deck, not decks with their own contents"; that finding is now
|
||
marked corrected in `open-questions.md`.
|
||
|
||
**It was destroying cards.** `cardDiscarded` did `departments[toSlot] = cardId` — assignment, not a
|
||
push — so discarding onto an occupied Department annihilated the card already face up there. A closed
|
||
deck was quietly leaking. It survived because the only card-conservation test ran at setup and never
|
||
again; there is now one that counts after a full game, and one that asserts no id is ever in two
|
||
places at once.
|
||
|
||
**And the choice was invisible.** All three discards described themselves as `discard X`, and the
|
||
action list drops duplicate labels — so three genuinely different decisions collapsed into a single
|
||
button and the Department could not be picked at all. Discards now read `discard Freight House onto
|
||
Department 2, burying Brakeman`, and a draw reads `take Depot from Department 1, 3 buried beneath
|
||
it`. The browser shows each pile's top card with a `+n under` count, because a deep pile is where
|
||
cards have been put beyond reach and that is what a discarding player is choosing between.
|
||
|
||
**Refill timing changed with it.** §6.2 refills an *empty* Department from the Home Office deck. The
|
||
old code refilled after **every** Department draw, which was harmless when a slot held one card and
|
||
would now drain the deck into the piles. It refills only when taking the last card empties one.
|
||
|
||
**The bot got the strategy this opens up.** It used to take the first discard option, always
|
||
Department 1, burying whatever sat there — including the Depot it was waiting on. It now covers the
|
||
face-up card least worth keeping reachable, never one it would take, and breaks ties toward the
|
||
shallowest pile. Measured over 100 games: spreading discards **2.87** revenue, concentrating them on
|
||
the deepest pile 2.67, indifferent 2.67. Three piles offer three face-up cards, and piling onto one
|
||
of them leaves the other two showing whatever they started with.
|
||
|
||
In a competitive game the same call reads the other way round — burying a card a rival wants is an
|
||
attack rather than housekeeping — which is exactly why the choice belongs to the discarding player.
|
||
|
||
**Measured, 100 solitaire Standard games:** Departments hold 8.3 cards between them at game end,
|
||
deepest single pile 17. The Home Office deck ended with 176.6 of 243 and **never ran dry**, which
|
||
matters because §6.2's reshuffle — collect the Salvage Yard and all three Departments, reshuffle,
|
||
re-establish the deck — **is not implemented**. It has never been reachable in a 5-Day solitaire
|
||
game; it will matter for Campaign length and for four players. *(Implemented in a later entry.)*
|
||
|
||
### Track is a deck card, not a private supply
|
||
|
||
Reported by the designer: *"all track cards are included in the home office deck and are played from
|
||
there like any other card."*
|
||
|
||
The error is visible in the spreadsheet. `docs/Deck cards2.xlsx` has a column B headed **"Number in
|
||
Deck"** — 32 straights, 16+16 curves, 4+4 sharp curves, 16+16 turnouts, **104 cards** — and a LAST
|
||
column headed **"Track Per Player"** reading 8/4/4/1/1/4/4 = 26. The code took the last column as a
|
||
separate physical stack and wrote *"Track is NOT in the Home Office deck — this is the single biggest
|
||
structural change from the placeholder."* It is 104 shared among four players, not a second pile. The
|
||
sheet's own totals settle it: "Sum other 115", "Total track 104", grand total 231 — and 115 + 104 +
|
||
12 start cards is exactly 231.
|
||
|
||
**What went.** `TRACK_SUPPLY`/`TRACK_PER_PLAYER`, `OfficeArea.trackSupply`, the `track.lay` intent,
|
||
the `trackLaid` event, `protoTrackCard`, `TurnState.laidThisTurn` (the one-piece-a-turn cap, which
|
||
only existed because a private supply had nothing else bounding it), and the "Your Track Supply"
|
||
panel. `CardKind` for track gained a `hand`, because handedness is the diagonal and a track card
|
||
without one cannot say what it may be joined to.
|
||
|
||
Most of the plumbing was already there and dead: `checkPlay` had a `track` branch, `protoCard` had a
|
||
`track` branch, and the `cardPlayed` reducer already placed a track card with a rotation. Track had
|
||
been a card once, and moving it back was largely deleting the parallel path.
|
||
|
||
**The deck is 243 cards, of which 104 are track** — the largest category by some way, and the point
|
||
of the change: building a district is now paid for in the industry or train you did not draw, and a
|
||
three-card hand is the real constraint on how fast a railroad grows.
|
||
|
||
**Measured over 100 solitaire Standard games, against the same run with track as a private supply:**
|
||
|
||
| | private supply | in the deck |
|
||
| --- | ---: | ---: |
|
||
| deck size | 139 | 243 |
|
||
| district size | 28.5 cards | 28.0 |
|
||
| mean max depth off the main | 1.45 rows | 1.94 |
|
||
| turnouts / curves / straights per game | 7.6 / 7.2 / 3.4 | 5.6 / 7.4 / 5.6 |
|
||
| facilities placed | 4.56 | 3.84 |
|
||
| districts with a run-around | 70/100 | **29/100** |
|
||
| facilities on a run-around | 0.51/game | **0.18** |
|
||
| revenue | 5.80 mean | **3.05** |
|
||
|
||
**Revenue nearly halved, and that is the headline for the next decision, not a defect to paper
|
||
over.** A run-around needs a turnout, a matching curve, straights, a second curve and a second
|
||
turnout — all of the right hand, arriving in a three-card hand in a usable order. It used to be a
|
||
shopping list; it is now a draw. Two test floors were re-baselined against the measurement with the
|
||
old figure recorded beside them, deliberately set BELOW what was measured so they detect the loop
|
||
machinery breaking rather than endorsing 29%.
|
||
|
||
**Two bot fixes fell out of it, both real.** Track became an ordinary `card.play`, so the generic
|
||
"play anything placeable" fallback started dumping track on whatever square was legal — bypassing
|
||
`bestTrackLay`, which had already looked at the same piece and declined it. Measured: 26 of 60
|
||
districts ran the siding past the last column with a way up. A track card the scorer will not use is
|
||
a card to discard. And `arcsLeft`, which asked a supply that no longer exists, became `arcInHand`:
|
||
"have I got a curve of this hand?" is now a question about the hand, not a certainty.
|
||
|
||
**Still open, and now urgent.** The office counts are doubled (Depot 4→8, Station 2→4, Terminal 1→2,
|
||
Q12) and the industries tripled (Gap 12), both tuned by measuring a deck with **no track in it** —
|
||
25 of 100 games never drew a Depot and never escaped Whistle Post. Adding 104 cards dilutes every
|
||
draw by 43%, which is exactly what those multipliers were compensating for, so they are now either
|
||
badly needed or badly wrong and only a measurement will say which. `test/setup.test.ts` records both
|
||
departures and flags them for re-measurement.
|
||
|
||
### The placement labels described the mirror of the card they would lay
|
||
|
||
Reported as "I can't play a turnout north or south of an existing turnout". It was always legal — a
|
||
turnout under a turnout is a **crossover**, and it is how a siding gets a track running parallel to
|
||
the Running Track. The engine accepts left-over-left and right-over-right today, and the square was
|
||
offered and clickable. What was wrong was the words on it.
|
||
|
||
`rotationNote` in `src/web/game.ts` called `variantsFor(geometry)` **without the hand**. Hand is the
|
||
diagonal, so without it `variantsFor` answers for the left-hand card whatever you are holding. Every
|
||
right-hand turnout was offered as *"stem west, through east, diverges south"* — the exact mirror of
|
||
the card it would lay — and every right-hand curve named the wrong edge. The placement was always
|
||
correct and only the description lied, which is the kind of bug that survives a green test suite and
|
||
makes a working feature feel broken.
|
||
|
||
Fixed by threading `hand` through `rotationNote` → `variantLabel`, and covered by a test that walks
|
||
every geometry × hand × rotation and checks the label against `variantsFor`'s own answer.
|
||
|
||
**And a placement now says what it would connect to.** Two cards meeting at an edge is not a rail —
|
||
on a north or south edge their 45° legs must also share a diagonal — so "is this square legal" and
|
||
"does this piece meet the one I am aiming at" are different questions and only the first was on
|
||
screen. Spots now read `(-1, 1) — stem east, through west, diverges north at 45° · joins the track
|
||
above`, which is the crossover named outright.
|
||
|
||
### A New game button, instead of finishing the one you have
|
||
|
||
`start()` restores from localStorage on every load and the only "new game" button lived on the
|
||
game-over screen, so a game you no longer wanted followed you across reloads with no way out.
|
||
|
||
`New game` sits beside `Save replay` in the header. It confirms once past the opening Stage — the
|
||
save *is* the game, there is no undo, and the replay download is right there — then clears the save
|
||
and reloads. It drops any `?seed=` from the URL as well: leaving it would deal the same game again
|
||
and look like the button had done nothing.
|
||
|
||
### The track is 45° geometry, drawn from the printed cards
|
||
|
||
The prototype designer's feedback: **there are no north–south tracks.** Straight runs are always
|
||
east–west, the east–west line sits dead centre on the card rather than biased to the top, and a
|
||
turnout is an east–west through track plus a curved leg meeting the north or south edge at 45°, which
|
||
must line up with the corresponding leg on the card it abuts.
|
||
|
||
Measured off `docs/tracks.png` rather than inferred. Card grid lines at y = 15/164/314/464/614/764/
|
||
914/1064/1213 on a 2136×1397 sheet; rail centres at 89.5/236/–/–/689.5/839/989/1135.5/1288.5 — the
|
||
card's exact vertical middle, within a pixel, on every one of the nine rows. Cards are 259×150
|
||
(aspect ≈ 1.73). Every leg crosses the middle of its edge. Four shapes exist: turnout with the leg
|
||
off the west end, turnout with the leg off the east end, curve west↔south, curve east↔south, plus the
|
||
plain straight. Depot, Station, Terminal, Refinery, Freighthouse, Coal Tipple, Manufacturing and Town
|
||
are all plain east–west straights with **no diverging leg at all**.
|
||
|
||
**Slope is now part of adjacency.** Name the two diagonals after the pair of arcs that mate across a
|
||
horizontal card edge: `ne_sw` (port pairs {n,e} and {s,w}) and `nw_se` ({n,w} and {s,e}). An `sw` card
|
||
above an `ne` card is one unbroken rail; `sw` above `nw` is a V — both cards have the port, both legs
|
||
meet the same point on the edge, and they still do not connect. `joins(a, p, b)` in
|
||
`src/engine/track.ts` is the single adjacency test, and every `hasPort(nb, opposite(p))` in the
|
||
engine, the bot and the tests now goes through it. Leaving one behind reintroduces the V.
|
||
|
||
**Handedness is that diagonal.** A printed card has a back: it turns 180° but never flips. So a
|
||
straight has ONE orientation, and a curve or turnout has two — 0° sends the 45° leg south, 180° sends
|
||
it north, and neither changes diagonal. Left-hand reaches `sw`/`ne`, right-hand `se`/`nw`. That is
|
||
what makes the 4-left/4-right split of the curve and turnout supply mean something: a run-around needs
|
||
one card of each hand — a left turnout down, its matching left curve, straights along, a right curve
|
||
back up into a right turnout. `variantsFor` takes a `hand` argument for this reason.
|
||
|
||
**What went, and what opened.** `TrackAxis` and every `axis` field are deleted outright — there is
|
||
one axis now, and a one-valued field invites the old branching back; deleting it turned `tsc` into
|
||
the migration checklist. The Office's `e-s`/`w-s` stubs are gone, as are north–south facility
|
||
placements. Q7's "a district hangs below the Running Track" is lifted: a turnout turned 180° reaches
|
||
north, so `placementCandidates` and `adjacentFacilityCoord` no longer reject the rows above, and §9's
|
||
"nine nearby spots" really is nine. `legalIntents` offered six variants per placement per card; the
|
||
widest set is now two.
|
||
|
||
**The renderer draws the card rather than approximating it.** `RAIL` moves from 30 to `H/2`, and the
|
||
card widens from 132×96 to 166×96 to match the sheet's 1.73 — the aspect is not decoration, since the
|
||
frog where a 45° leg meets the through track sits `H/2` from the centre and a squarer card pushes it
|
||
almost to the edge. The fixed elbow at `(W/2, RAIL + (H-RAIL)/2)` is replaced by the real frog at
|
||
`(W/2 ± H/2, H/2)` and a segment at exactly 45° to the edge midpoint. That elbow sat *below* the rail
|
||
on the assumption everything diverged downward, so a leg reaching north was drawn as a hook that
|
||
dropped past the rail and came back up. Four tests in `test/web.test.ts` now measure the emitted SVG:
|
||
the through rail level and centred and spanning the full card, every leg at 45°, `ne`/`sw` on one
|
||
diagonal and `nw`/`se` on the other, and the leg crossing the edge at its midpoint. The enhancement
|
||
label moved above the rail, where it used to print along it.
|
||
|
||
**Measured, 100 solitaire Standard games, against the same run before the change:**
|
||
|
||
| | before | after |
|
||
| --- | ---: | ---: |
|
||
| mismatched 45° joints on finished boards | — | **0** |
|
||
| district size | 25.4 cards | 28.5 |
|
||
| mean max depth off the main | 2.38 rows | 1.45 |
|
||
| built above the Running Track | 0/100 games | 93/100 |
|
||
| straights laid | 1.01/game | 3.44 |
|
||
| districts with a run-around | 99/100 | 70/100 |
|
||
| facilities on a run-around | 0.82/game | 0.51 |
|
||
| revenue | 7.34 mean | 5.80 |
|
||
|
||
The shallower districts are intrinsic: no card joins north to south, so descending a row costs at
|
||
least two cards — the leg out, then a curve turning the run back east–west. Districts are now wide
|
||
and shallow, which is what the printed sheet and a real yard both look like, and the run along the
|
||
siding is straights, which is why the bot lays three times as many (13 of the 18 enhancement cards
|
||
need one).
|
||
|
||
**The revenue and run-around drops are honest, not a regression to chase.** A run-around costs more
|
||
pieces than it did, and the Office no longer offers a free way back up onto the main — it used to
|
||
carry `e-s`/`w-s` stubs, so every district had one guaranteed climbing point. Two bot heuristics were
|
||
tried against it and both rejected on measurement: preferring one side of the main changed nothing at
|
||
−4 and made things worse at −100 (62/100 run-arounds), and paying more for the second turnout that
|
||
closes a siding bought 86/100 run-arounds and 1.08 facilities on a loop but cost 9% of revenue
|
||
(5.80 → 5.25). Revenue is the game's own measure, and both structural floors in `test/sim.test.ts`
|
||
still pass, so neither shipped.
|
||
|
||
The bot is otherwise rewritten around the new geometry: `descendFrom`/`waysOff`/`reachableOffMain`/
|
||
`runAroundCells` take a side rather than assuming "below", the arc score asks `joins` instead of
|
||
reading the arc name, and `arcsLeft` is asked per hand — a left-hand turnout's leg can only be
|
||
continued by a left-hand curve, and a supply full of right-hand ones is no help to it.
|
||
|
||
### Q13 — a train that catches the one ahead runs into it
|
||
|
||
Answered, and implemented as option B: **collide on catching up**, which is the version that rewards
|
||
judging the gap.
|
||
|
||
§10 makes a Mainline collision the Superintendent's fault and removes both trains, and ABS Signals
|
||
exists to stop trains rear-ending each other — but §8.3's trigger list never named one and nothing
|
||
was implemented, so granting clearance was FREE: both trains survived, no penalty, and ABS Signals
|
||
protected against nothing. Now a following train that closes on the one ahead runs into it, and one
|
||
that never closes is fine, so clearance is a bet on relative speed rather than a formality. §2.1
|
||
divides the card into two regions, and sharing one is what "caught up" means. ABS Signals does what
|
||
it prints instead: the follower stops short and holds.
|
||
|
||
**Not on a card that prints "trains may pass".** The first version fired 0.41 times a game while the
|
||
bot never once granted clearance, which is the tell — those were all Double Track and Uncontrolled
|
||
Siding, cards that hold two trains because they HAVE two roads. Catching up there means going past,
|
||
which is what the card is for.
|
||
|
||
With that corrected the mechanic is invisible to the current bot, because it always denies
|
||
clearance. That is the right shape, and the teeth are real:
|
||
|
||
| Superintendent | revenue | rear-enders | ABS holds |
|
||
| --- | --- | --- | --- |
|
||
| always denies (the bot) | 7.34 | 0.00/game | 0 |
|
||
| **always allows** | **−5.13** | **2.20/game** | 19 |
|
||
|
||
So the bot's always-deny policy — a deliberate choice made when clearance was free and the
|
||
arithmetic only guessed at — turns out to be correct, and is now correct for a measured reason.
|
||
|
||
Tested deterministically rather than through the bot: two trains built onto one single-track card
|
||
with the follower closing, which collides whichever order the phase processes them in, and the same
|
||
pair again with ABS Signals to prove it holds instead.
|
||
|
||
### The engine has a place in the train, and the yards are visible
|
||
|
||
**The engine had a position the game recorded and never used.** `engineFront` was a boolean, written
|
||
in three places and read in none — so every consist was drawn as an anonymous row of cars. It is now
|
||
`engineAt`, an index into the consist, because a Crew Tray is an engine plus its Rolling Stock and
|
||
the engine may be PULLING (ahead of everything), PUSHING (behind everything), or in the middle doing
|
||
both at once. A boolean cannot say the third thing. Consists are drawn with `ENG` where it sits.
|
||
|
||
The engine is deliberately NOT one of the `consist` entries: §8.2 counts the consist as Rolling
|
||
Stock, and the four-car limit (§A.4) is a limit on cars, not on the locomotive hauling them.
|
||
|
||
**Both yards are now on the page**, by car type and split loaded / empty, with the Division Yard
|
||
outlined the moment it goes bare. This matters more than it did: the Classification Yard returns to
|
||
service only when the Division Yard is empty, so the supply genuinely runs down, and a game that
|
||
never showed either yard gave no warning at all.
|
||
|
||
It shows the pressure immediately. In a finished game the Division Yard held 30 cars — hoppers,
|
||
tanks and cabooses — and **no boxcars, coaches or reefers at all**, while 19 of them sat in
|
||
Classification unable to come back, because the Division Yard was not bare.
|
||
|
||
Crew Tray scarcity was already implemented and is left alone: a train with no free tray is held
|
||
(`trainHeld`), which is §7.
|
||
|
||
### The Classification Yard rule, from the source — and my guess was worth 2.4 Revenue it should not have been
|
||
|
||
Answered: used Rolling Stock is set out in the Classification Yard, used engines and cabooses go
|
||
straight back to the Division Yard, and **the Classification Yard empties only when the Division
|
||
Yard is bare** — then all of it returns at once.
|
||
|
||
That is a much harder rule than the one I invented. Returning cars at every DAY boundary keeps the
|
||
yard topped up continuously; this lets it run down to nothing and refill in one go, which is the
|
||
whole of the supply pressure the game is meant to have. Measured paired over 400 seeds:
|
||
|
||
my Day-boundary guess 9.67 the real rule 7.25
|
||
paired change -2.42 ± 0.49 (t -9.67) 235 of 400 seeds affected
|
||
|
||
So the +2.32 celebrated when the Classification Yard was first made readable was very largely an
|
||
artefact of getting the trigger wrong. The refill is now checked wherever a car leaves the Division
|
||
Yard, so it fires the moment the yard empties rather than at the next convenient tick.
|
||
|
||
**Poling is dealt zero copies** rather than deleted. Its effect is "TBD in the source", so there is
|
||
nothing to implement and a card that cannot be played is worse in a hand than absent from the deck.
|
||
The catalogue entry stays so the gap remains visible. Deck 140 → 139, solitaire 118 → 117.
|
||
|
||
**Heavy Grade orientation stays rolled from the seed**, and is now documented as temporary in the
|
||
code rather than only in TODO: the card says the player sets it, but it is dealt during setup and
|
||
setup has no decision point — `createGame` is a pure function of the seed, which is also what makes
|
||
a save portable.
|
||
|
||
**A counting question this raised, and could not answer.** A census of every holder of rolling stock
|
||
comes to 92 against the 80 dealt. That is not proof of duplication: `outboundBox`, `inboundBox` and
|
||
`menAtWork` all hold `RollingStock`, and stocking a green box takes a LOADED CAR out of the Division
|
||
Yard — so some of those objects are cargo in transit rather than cars, and nothing distinguishes
|
||
them. An accounting test was written and then withdrawn, because it could not tell the two apart.
|
||
Logged: until a load is its own type, "is any stock being created or destroyed?" is unanswerable.
|
||
|
||
### The freight figures were counting one half of freight
|
||
|
||
A measurement fix, not a game fix — but it is the instrument every balance decision is read from.
|
||
|
||
`rev.freightUnload` was assigned `eventCounts['unloadBegan']`: unloads STARTED, not Revenue EARNED,
|
||
and the two differ by every unload that never finished. `grossFreight` then used `freightLoad`
|
||
alone, so `freightShare` omitted the unload half outright. A completed load and a completed unload
|
||
each earn a point, on two distinct `revenueChanged` reasons.
|
||
|
||
The comment that stood there claimed "an unload scores through the same event as a load completion
|
||
in the reducer". It does not — `apply.ts` emits `freightUnload` separately. A comment asserting a
|
||
fact about code a few lines away, and wrong.
|
||
|
||
freight share of gross: 39% -> 49%
|
||
|
||
The harness now prints both halves. `strategyBuckets` counted "a scoring game" the same wrong way,
|
||
and so did the test guarding it — so a game that scored only by unloading was bucketed but not
|
||
counted, which is how the fix first showed up as a failure.
|
||
|
||
This matters backwards as well as forwards: the "freight is only 13-18% of gross" finding was read
|
||
from this number, and it is what drove the Gap 12 industry-density change. That decision was taken
|
||
against an instrument reading roughly 60% low.
|
||
|
||
### Turnouts that go nowhere — reported, confirmed, and mostly not the problem
|
||
|
||
Reported from two replays: of 8 turnouts off the Running Track, 2 formed a run-around, 2 served
|
||
industries and 4 went nowhere. Measured across 120 games, that holds exactly:
|
||
|
||
| what a turnout leads to | |
|
||
| --- | --- |
|
||
| part of a run-around | 30% |
|
||
| a stub, but serves an industry | 12% |
|
||
| a stub ending in bare track | 44% |
|
||
| **nothing below it at all** | **15%** |
|
||
|
||
**4.61 wasted turnouts a game.** Then three attempts to stop it, each measured paired over 400
|
||
seeds, and each WORSE than leaving it alone:
|
||
|
||
| attempt | paired change | |
|
||
| --- | --- | --- |
|
||
| no new way down while one leads nowhere | run-arounds → **0** | deadlocked: a run-around needs TWO ways down, and the second cannot be justified by what hangs off the first |
|
||
| first two free, gate the rest | **−0.84 ± 0.53** (t −3.08) | track spend collapsed 15.4 → 4.8 |
|
||
| forbid rail that butts an incompatible card | **−0.62 ± 0.57** (t −2.11) | |
|
||
|
||
The reason is that a turnout is not only a way DOWN. It is also a way UP, and both the east-west
|
||
extension and the closing arc are gated on one existing beyond them — so cutting the turnouts cuts
|
||
the places a siding can rejoin, and the sidings stop forming too. The apparent waste is optionality.
|
||
This also re-confirms, with proper statistics, a note left in the code by an earlier attempt.
|
||
|
||
### Rail that can never go anywhere
|
||
|
||
The one that did work, and only as a tie-breaker.
|
||
|
||
A port facing an EMPTY square is a promise: something may be built there later. A port butting an
|
||
OCCUPIED square whose card has no matching port is not — that square is taken, so the rail stops
|
||
dead and always will. Reported from seed 618682, where an arc came off a turnout with its far end
|
||
jammed into a curve that could not accept it. Measured: **28% of all pieces laid, 4.26 a game.**
|
||
|
||
Forbidding it cost 0.62 revenue a game. Applying it as a **tie-breaker on the distance score** —
|
||
never able to veto a piece, only to choose between two the heuristics rate equally — measured
|
||
**+0.43 ± 0.49 (t 1.74)**, with 108 seeds better against 67, and cut these from 28% of pieces to 7%.
|
||
Not significant on its own, but it is the only one of four attempts pointing the right way, and the
|
||
mechanism is sound.
|
||
|
||
**And it fixed the reported problem after all — sideways.** Re-running the turnout taxonomy:
|
||
|
||
| what a turnout leads to | before | after |
|
||
| --- | --- | --- |
|
||
| part of a run-around | 30% | **66%** |
|
||
| a stub, but serves an industry | 12% | 13% |
|
||
| a stub ending in bare track | **44%** | **2%** |
|
||
| nothing below it at all | 15% | 18% |
|
||
| **wasted per game** | **4.61** | **1.65** |
|
||
|
||
Bare stubs all but gone and run-arounds more than doubled, without ever refusing a turnout. Refusing
|
||
them directly had destroyed the run-arounds; declining to lay rail INTO a dead end leaves the bot
|
||
free to cut every turnout it likes and quietly stops it building the stubs. The remaining waste is
|
||
the last turnouts of a game, cut with no turns left to build beneath them.
|
||
|
||
Also added, and honest about it: a turnout is not cut when no arc remains to hang beneath it.
|
||
Measured at **0%** today — the bot lays the arc immediately after the turnout and never runs the
|
||
supply dry — so it is a guard against the supply changing rather than a fix for anything happening
|
||
now. Its test constructs the situation by draining the arcs.
|
||
|
||
### A source file git was hiding
|
||
|
||
Found while checking the above: **`src/web/replays.html` had never been committed.** `.gitignore`
|
||
carried `replay*.html` to catch the throwaway files generated at the repo root, and unanchored it
|
||
also matched a source page — the website's replay viewer. `git archive HEAD` confirms it: a fresh
|
||
clone does not contain that file, and `build-web.ts` copies it unconditionally, so the build would
|
||
have failed for anyone but this working copy.
|
||
|
||
The pattern is anchored to the root now (`/replay*.html`), which still ignores the generated files
|
||
and no longer ignores the source. A test asks GIT — not `.gitignore` — whether each source page
|
||
would survive a clone, because that is the actual question.
|
||
|
||
### Three places draw a game, and they had drifted
|
||
|
||
Reported from playtesting: the replay had lost its "extra slow" speed, and neither the sound nor the
|
||
auto-hide could be found. Both true, and the same cause — a game is drawn in THREE places and only
|
||
some of them had kept up:
|
||
|
||
| | speeds | sound | auto-hide |
|
||
| --- | --- | --- | --- |
|
||
| the playable page | — | yes | yes |
|
||
| the standalone replay file | 5 | yes | yes |
|
||
| **the website's replay viewer** | **3** | **no** | **no** |
|
||
|
||
The website viewer is its own implementation — it replays a save through the engine in the browser
|
||
rather than reading a rendered file — and it never got what the other two grew. It now has all five
|
||
speeds (extra slow through very fast), the sound, and the auto-hide, using the same shared `cuesFor`
|
||
and `playCue` as everywhere else.
|
||
|
||
Three tests hold them together from now on: the two viewers must offer the SAME set of speeds, all
|
||
three pages must carry the sound and auto-hide controls, and `replays.ts` may not ask for an element
|
||
its page does not have — the same total check the playable page already had, and the one that would
|
||
have caught this. Verified by removing a speed and a control and watching them fail.
|
||
|
||
### The published replays had stopped replaying
|
||
|
||
Both saves in `public/replays/` were dead. A save is a seed plus the intents, replayed through the
|
||
real engine — so it cannot describe a position the rules could not produce, and an intent that no
|
||
longer applies stops the replay rather than being forced. That is the safe direction, but it is
|
||
silent: `seed-202` got 42 intents into 360 before halting, and `seed-430` managed 4 of 338.
|
||
|
||
They were recorded before this run's rules work — Modifier hosts, the industry-to-car mapping, the
|
||
Classification Yard — so most of what they described is no longer legal. Replaced with three games
|
||
generated against the rules as they stand, each verified to replay every intent to the final Day:
|
||
|
||
| | |
|
||
| --- | --- |
|
||
| A winning run | 36 Revenue, seed 1038389 |
|
||
| A strong run | 32 Revenue, seed 618682 |
|
||
| Collisions | 10 Revenue and 27 smashes, seed 919604 |
|
||
|
||
The third is deliberately a bad game: a full Office is a collision, and it costs more than the
|
||
freight was worth.
|
||
|
||
This is the "save/restore is not version-aware" item in TODO doing exactly what it warns about. The
|
||
saves are cheap to regenerate, so the fix is not to freeze them — it is for a stale save to say so
|
||
instead of quietly ending early.
|
||
|
||
### The replay behaves like the game it is replaying
|
||
|
||
Sound and the district auto-hide were built for the playable page and the replay had neither, which
|
||
made watching a game back a poorer experience than playing it. Both are there now, and both are the
|
||
SAME implementation rather than a second copy:
|
||
|
||
- `cuesFor` decides what happened — a Stage ended, a Day turned, a train was built — and is imported
|
||
by the live game and called by the replay recorder, so the two cannot disagree about when a Stage
|
||
ended. Frames carry their cues.
|
||
- `playCue` decides what that sounds like. It was three module-level functions with a shared
|
||
`AudioContext`; it is now one self-contained function, embedded into the replay by `toString()`
|
||
exactly as the board renderers are. The context is parked on `window` because the embedded copy
|
||
has no module scope to keep it in.
|
||
|
||
**Sounds fire only when stepping FORWARD one frame.** Scrubbing across a hundred frames would
|
||
otherwise fire a hundred whistles at once, and stepping backwards would sound a Stage ending that is
|
||
being un-done.
|
||
|
||
Both default to the quiet, tidy setting: muted, and auto-hide on.
|
||
|
||
The replay's DOM stub had no `classList`, so the page threw while rendering rather than folding a
|
||
panel. It has one now — the same gap the playable page's stub had, and the same fix.
|
||
|
||
### Playtest fixes
|
||
|
||
**The end-of-line labels were cut to three letters.** "trains enter" and "trains leave" hang off the
|
||
ends of the route, and the canvas padding was sized for the buffer stops alone — so the west end read
|
||
"TER" and the east "tra". Padding now allows for the words.
|
||
|
||
**"End my turn" became "End Local Operations".** It ends the phase, and the old wording invited the
|
||
reading that it ended something smaller.
|
||
|
||
**The hand limit is a limit, not a toll on drawing.** Drawing a card set "you must now play one",
|
||
so a player who had already played two cards and drew back to three was still forced to spend one.
|
||
§6.2 is a hand LIMIT — "reduce his hand to no more than three cards", four with a Red Flag — and
|
||
that is now the only thing that blocks the end of a turn. It is derived from the hand at each
|
||
render, and a test asserts the page and the engine never disagree about whether the turn may end.
|
||
The engine removes `draw.end` outright when the hand is over the limit, so the page draws a disabled
|
||
button naming the reason rather than silently offering no way out.
|
||
|
||
**A train now says what its card calls for.** Reported on seed 22222: Extra X22 offered a caboose
|
||
and nothing else with no reason given. The rules were right — "Pee-Dee" is a per-diem train whose
|
||
consist is one caboose and no cars, and §8.2 forbids the wrong cars however few are carried — but
|
||
the screen never said so, which reads as a broken game. The New Train header now names the train and
|
||
its consist: *Making up Extra X22 "Pee-Dee": its card calls for 1 caboose — Per-diem train. May only
|
||
pick up MTs.*
|
||
|
||
**The auto-hide control said what the panel was doing, not what pressing it would do.** "auto ·
|
||
folded" reads as a status line and was missed; it now reads "auto-hide: on — click to keep open",
|
||
and looks like a control rather than a caption.
|
||
|
||
**The whistle is twice as long,** and sound now defaults to OFF.
|
||
|
||
### A legal move you could not click
|
||
|
||
Reported from playtesting: laying a straight offered three places in the list and highlighted one on
|
||
the board.
|
||
|
||
Empty squares were drawn by a separate `ghostSvg` and spliced into the SVG afterwards. It computed
|
||
its origin over cells PLUS the offered spots, while `officeSvg` sized itself over cells alone — so
|
||
the two disagreed the moment a legal square lay outside the played cards, which is every square that
|
||
would EXTEND the district. Reproduced on seed 555: three placements offered, the empty one drawn at
|
||
y=99 on a canvas 103 tall, i.e. off it entirely.
|
||
|
||
`officeSvg` now takes the ghosts and sizes itself over both, so there is one origin and one canvas,
|
||
and `ghostSvg` is deleted rather than fixed — a second coordinate system was the bug, not a detail
|
||
of it. It was also imported by the replay and never called there.
|
||
|
||
A test walks six seeds and asserts every square the menu offers has a target drawn inside the
|
||
canvas: 129 squares across 47 placeable subjects. It fails on the old code with the exact
|
||
coordinates above.
|
||
|
||
### The railroad, heard
|
||
|
||
A whistle at the end of each Stage, the grade-crossing bell at the end of each Day, and the
|
||
conductor when a train is built. **Off by default** — everything here is synthesised rather than
|
||
recorded, so it is a placeholder for real audio and a playtester who did not ask for noise should
|
||
not get any. One click in the title bar turns it on, and that click doubles as the gesture browsers
|
||
require before audio may start.
|
||
|
||
**Synthesised, not sampled**, and that is a constraint rather than a preference: the site is a static
|
||
folder that fetches nothing — there is a test asserting no page reaches an external host — so audio
|
||
would have to be committed to the repo, and a plausible whistle is not something to invent. A real
|
||
steam whistle is a CHORD of several chambers slightly out of tune with each other plus the breath of
|
||
the steam, which three detuned partials and a band of filtered noise get most of the way to. The
|
||
bell is inharmonic partials struck twice, which is what separates a bell from a beep.
|
||
|
||
**"All aboard" is speech, and speech cannot be faked with oscillators.** `speechSynthesis` is built
|
||
into the browser, needs no asset and works offline, so it says the words; where the platform has no
|
||
voice installed a two-note conductor's call takes its place rather than nothing happening.
|
||
|
||
The model names WHAT happened — a Stage ended, a Day turned, a train was made up — and the page
|
||
decides what that sounds like. A Day boundary rings the bell only: both would collide, and the bell
|
||
is the bigger event. The first Stage of the game announces nothing, because a Stage beginning is the
|
||
previous one ending and there is no previous one.
|
||
|
||
Counted against the clock rather than trusted: over a full game, 60 Stage boundaries produced 55
|
||
whistles and 5 bells, and 5 Days produced 5 bells.
|
||
|
||
### Regions, drawn from what the crossing already cost
|
||
|
||
§2.1 divides a Mainline card into two regions and §8.2 moves a train one region per Stage. The
|
||
engine had replaced that with `crossingStages()` — `implications.md` records it plainly: "The
|
||
`Region` model is gone" — because ten card types with real speeds cannot be expressed by one region
|
||
a Stage. Both `REGIONS_PER_MAINLINE_CARD = 2` and `entryPoints` survived as dead constants.
|
||
|
||
The map draws regions again without reinstating the mechanic, because the printed cards say how:
|
||
they carry **Start positions**, so a train with Brakemen *enters further along the card*. That is
|
||
the same fact as "takes a Stage off the crossing", in different coordinates. So position falls out
|
||
of what the engine already knows:
|
||
|
||
entry = REGIONS - stagesTotal position = clamp(entry + elapsed)
|
||
|
||
| crossing | drawn as | |
|
||
| --- | --- | --- |
|
||
| 1 Stage (a 60 card) | enters at region 2, gone next Stage | the printed Start position |
|
||
| 2 Stages (a 30 card) | region 1 → region 2 | **§8.2 exactly** |
|
||
| 3 Stages (slow train) | region 1 → region 1 → region 2 | fixed distance, slow train |
|
||
|
||
`entry` is deliberately allowed to go negative and only the final position is clamped: that keeps a
|
||
slow train's extra Stage at the START, where being slow shows. Clamping the entry instead parked it
|
||
at the exit, reading as a train that raced across and then waited — which is what the first version
|
||
did, and what the test now forbids.
|
||
|
||
`Transit` gains `stagesTotal`, set on entry. State, not rules: nothing reads it to decide anything,
|
||
so the bot and every balance figure are untouched, and a save is a seed plus intents so there is
|
||
nothing to migrate. It cannot be recomputed later — a modifier played onto the card mid-crossing
|
||
would change the answer and make the train jump backwards.
|
||
|
||
Division Points draw one region, the queue. Running Track cards inside a district draw none: a crew
|
||
moves there by Moves, not Stages, so it occupies a card outright.
|
||
|
||
### The Division map shows the whole route
|
||
|
||
The map drew one box per node, which collapsed each player's entire district into a single "Office"
|
||
tile — so the track a train actually runs along was invisible on the only view that shows where
|
||
trains are.
|
||
|
||
An Office now expands into its **Running Track, Limits to Limits**. That is the right cut rather than
|
||
a compromise: the through route IS the Running Track, and everything hanging beneath it is secondary
|
||
track a crossing train never touches. Sidings, industries and the load pipeline stay in the district
|
||
view, which is where they can be read.
|
||
|
||
West DP · Mainline · [Limits … Office … Limits] · Mainline · [ … ] · Mainline · East DP
|
||
|
||
**Trains are shown wherever they are.** On a Running Track card, on a Mainline card, queued at a
|
||
Division Point. A crew working BELOW the Running Track has no position on the through route, so it
|
||
is reported against the district — "2 switching below" — rather than drawn somewhere it is not.
|
||
Projecting a siding onto the through line would be a lie the map cannot support.
|
||
|
||
**Seating.** Players sit around a table, so the route is laid out the way they do: one row alone,
|
||
two rows facing, a horseshoe of three, a square of four. The Division is a LINE and not a loop —
|
||
trains enter at one Division Point and leave at the other — so the shape is deliberately left open,
|
||
with buffer stops at both ends and the gap labelled "trains enter" and "trains leave". Closing it
|
||
into a ring would promise a connection the rules do not have.
|
||
|
||
Layout is geometry with no visual feedback loop, so it is tested rather than eyeballed: for 1, 2, 3
|
||
and 4 players no two cells may overlap and none may fall outside the canvas.
|
||
|
||
### The district folds itself away
|
||
|
||
Auto-focus. The district is worth its vertical space during the phases that change it — Local
|
||
Operations and Cargo — and not during New Train, Mainline or Supervisor Shift, where the Division
|
||
map is what matters. Folded, it leaves a summary line rather than vanishing, because a panel that
|
||
disappears entirely reads as broken.
|
||
|
||
A manual toggle overrides it and stays put. This is DISPLAY state and never game state: two players
|
||
at the same table may reasonably want it set differently.
|
||
|
||
### The replay was storing the same things over and over
|
||
|
||
Halved, near enough: **3415 KB → 1877 KB** on a 735-frame game.
|
||
|
||
The TODO said to carry `links` forward. Measured first, that would have bought 5% of the `cells`
|
||
payload — `cells` is 62% of the file, and inside it the cost is elsewhere:
|
||
|
||
what 32% of cells long prose, identical on every turnout in the district
|
||
facility 24% of cells the SAME object already serialised in the frame's `facilities`
|
||
identity 26% of cells row/col/kind/label/running/links, fixed once the card is laid
|
||
|
||
So all three are interned. A card's identity and its description are written once for the whole
|
||
recording and referenced by integer, and a cell points at its facility instead of carrying a second
|
||
copy of it. What stays per-frame is what genuinely changes: the cars standing there, the crew, and
|
||
any Enhancement laid on the card.
|
||
|
||
`rehydrateCells` is exported and emitted into the page by `toString()`, the same trick the two board
|
||
renderers use — a second copy inside the page's inline script could drift from the packing and the
|
||
symptom would be a board drawing the wrong cards rather than an error. The round-trip test runs that
|
||
exact function over every frame, resolving nulls the way the page does.
|
||
|
||
### Rolling stock was leaving the game
|
||
|
||
Four things were tried against bot revenue. **One of them was worth more than everything else in
|
||
this changelog combined, and it is the one that had been ranked third and predicted not to matter.**
|
||
|
||
**The Classification Yard was write-only.** Seven places pushed cars into it — retired trains,
|
||
collisions, unjams, set-outs — and nothing in the engine ever read it. Rolling stock drained one way
|
||
out of the game: 30 cars dead by the end of a game, **37% of the 80 dealt at setup**. Gap 2c says
|
||
engines and cabooses return to the Division Yard and everything else to Classification; the
|
||
recovered rules never say how Classification empties.
|
||
|
||
**ASSUMPTION, flagged rather than derived:** sorting cars for redistribution is what a
|
||
classification yard is for, and a Day is its natural cycle, so they now return at the Day boundary.
|
||
This is a rules decision that wants confirming against the source.
|
||
|
||
Paired over 400 seeds: **+2.32 ± 0.52, t = 8.79**, 194 seeds better against 44. Revenue 6.70 → 9.02.
|
||
|
||
Why it was mis-ranked is worth recording. The Division Yard does not run *dry* in five Days — 16.6
|
||
loaded freight cars remain, empty in 2 games of 100 — so it was reasoned that supply could not be
|
||
binding. The aggregate was never the point: what starves freight is not having the RIGHT commodity
|
||
at the moment a green box needs stocking, and returning classified cars keeps the mix alive.
|
||
|
||
### Three things that did not work, kept because the measurement is the result
|
||
|
||
Measured paired over the same 400 seeds, which is the only way to see effects this size.
|
||
|
||
**Capping the draw option: worth nothing.** 62% of all Local Operations actions went to drawing and
|
||
12.6 of 29 cards drawn were discarded, so a draw into a full hand converts straight into a discard.
|
||
Refusing it: **-0.10 ± 0.13**, and 379 of 400 seeds byte-identical. The branch almost never fires.
|
||
|
||
**Pairing the two halves of a load: worth nothing.** Sampling every outbound industry at every
|
||
loadUnload phase, 76% of the time it had NEITHER a stocked green box NOR a spotted car, and only 5%
|
||
of Stages had a single workable facility. Letting the bot switch for a stocked box without waiting
|
||
for a train at the Office changed nothing measurable — folded into the -0.10 above. The diagnosis
|
||
was right and the prescription did not address it: there is usually nothing to switch.
|
||
|
||
**Waking a dead branch made things worse.** `chooseLocalOption` tested
|
||
`options.some(i => i.type === 'mainline.modify')`, but that intent requires `s.turn.option === 'draw'`
|
||
and the test runs while the option is still null — `legalActions` had already filtered it out, so the
|
||
branch could never fire and never had. Rewriting it to check the hand cost **-0.64 ± 0.32
|
||
(t = -3.90)**, 104 seeds worse against 35. Its comment claimed the value compounds like a train
|
||
card's; it does not. The branch is now deleted, with the measurement in its place.
|
||
|
||
**The Enhancements other than Interlocking are worth exactly nothing** — and cost nothing either:
|
||
**-0.01 ± 0.41 (t = -0.04)** when the bot is forbidden to place any of them. Left as they are; there
|
||
is nothing to gain by restricting them. Note the first attempt at this experiment showed 0/400 seeds
|
||
changed, which was the experiment failing rather than the answer: a `card.play` fallback with no
|
||
placement filter was still placing them.
|
||
|
||
| | before | after |
|
||
| --- | --- | --- |
|
||
| revenue (200 games) | 6.7 | **8.7** |
|
||
| wins | 6.5% | **12%** |
|
||
| freight loads | 2.8 | 3.2 |
|
||
| cars dead in the Classification Yard | 28.4 | **~0** |
|
||
|
||
### A Running Track with nowhere to put an Enhancement
|
||
|
||
Every penalty in the game has one cause. Across 100 games, all 48 were `collision: no free A/D
|
||
track` — 2.70 revenue a game, **27% of gross**, concentrated in about a fifth of games and
|
||
responsible for the −47 tail.
|
||
|
||
The rules already answer it. Interlocking prints "may stop an inbound train on the Limit Track", and
|
||
`advance.ts:621` holds the train at the Limits instead of colliding. **It had never once been
|
||
placed.** Nor had any train ever been held at the Limits.
|
||
|
||
The reason was structural and nothing to do with Interlocking. The bot builds minimal two-arc
|
||
run-arounds — an `ne` arc meets an `nw` arc directly — so it never needed a straight and laid none:
|
||
**0.00 straights on the Running Track across 100 games.** Interlocking, Water Column and Telegraph
|
||
all require one; Yard Office and Small Yard want a Secondary Track Straight; Telephone and Radio
|
||
chain off Telegraph. Thirteen of the eighteen Enhancement cards that go on the board were
|
||
unplayable. They were drawn 3.78 a game and placed 0.64.
|
||
|
||
`bestTrackLay` now scores one straight onto the Running Track. Enhancements placed went 0.64 → 3.01
|
||
across nine types where only Overpass had ever appeared, and Interlocking now reaches the board in
|
||
about a quarter of games.
|
||
|
||
**On whether it pays — measured properly, and the answer is qualified.** Revenue per game has a
|
||
standard deviation of ~9, so a 100-game run carries roughly ±1.0 of noise. Run against the same 400
|
||
seeds, paired:
|
||
|
||
| | without | with |
|
||
| --- | --- | --- |
|
||
| revenue | 5.99 | **6.70** |
|
||
| collisions cost | 2.70 | **1.91** |
|
||
| worst single game | −47 | **−24** |
|
||
| most collisions in a game | 10 | 6 |
|
||
| wins | 5.3% | 6.5% |
|
||
|
||
Paired per-seed the change is **+0.70 ± 0.74 (95% CI), t = 1.87** — not significant on its own. And
|
||
154 seeds improved against **165 that got worse**: the mean gain comes from removing catastrophes,
|
||
not from making a typical game better. What justifies keeping it is that the mechanism is measured
|
||
directly and accounts for the whole effect — collision cost falls 0.79, and revenue rises 0.70.
|
||
|
||
**A correction to the numbers already in this file.** The per-100-game revenue figures reported for
|
||
the earlier changes carry the same ±1.0 noise, so the individual steps (5.0 → 6.0 → 6.5) were stated
|
||
more precisely than the sample supports. The cumulative move from 3.2 to ~6.7 is far larger than the
|
||
noise and stands; the individual increments should be read as indicative only.
|
||
|
||
### The bot was throwing away its own freight
|
||
|
||
**Routing turned out not to be the problem, which is why it was worth measuring first.** Of 1650
|
||
drops across 100 games, 940 (57%) landed on a facility that wanted the car and **zero** landed on
|
||
one that did not. The crew makes 9.4 correctly-targeted drops a game; the one-move-only destination
|
||
test costs nothing measurable. "70% of waiting loads have nothing spotted" was a misleading signal —
|
||
the cars arrive.
|
||
|
||
Following the freight from the other end found the leak. Loads were being **destroyed**:
|
||
|
||
stockToOutbound 9.45/game
|
||
loadStarted 2.71/game
|
||
facilityUnjammed 3.10/game from outbound <- healthy waiting loads, discarded
|
||
|
||
`facilityUnjammed` from `outbound` splices the load out of the green box and pushes it to the
|
||
classification yard. That load cost a Local Operations action to stock, so discarding it is strictly
|
||
negative — and the bot did it more often than it started a load.
|
||
|
||
**Two fallbacks meeting, and the root cause is a familiar one: two rules for one act.**
|
||
`canStockProductively` decided the Freight Agent option was worth taking if a matching empty car was
|
||
spotted. The engine's `stockOutbound` additionally requires a **loaded car of that commodity in the
|
||
Division Yard** — which the predicate never checked. So the option was chosen believing a box could
|
||
be stocked when none could; the follow-through then found nothing stuck, nothing to clear and
|
||
nothing stockable, and fell through to "clear whatever is stuck" with nothing stuck.
|
||
|
||
Fixed by making the predicate ask the same question the engine does, by no longer using Freight
|
||
Agent as the idle default (switching at worst moves the crew toward the Office, which a train must
|
||
reach to depart at all, §8.1), and by ordering the last-resort unjam by what it costs to lose —
|
||
MEN|AT|WORK first, then the red box whose Revenue is already banked, and the green box last.
|
||
|
||
| | cars fixed | freight kept |
|
||
| --- | --- | --- |
|
||
| loads discarded from a green box | 3.10 | **0.00** |
|
||
| revenue | 6.0 | **6.5** |
|
||
| wins | 5/100 | **8/100** |
|
||
| trains scheduled | 3.0 | 3.4 |
|
||
| cards played | 16.6 | 19.3 |
|
||
|
||
**The gain is development, not freight.** Loads started held at 2.70 and freight revenue at 2.6 —
|
||
the recovered Local Operations actions went into drawing and switching rather than into the freight
|
||
chain. Green-box stocking fell 9.45 → 6.34 because the bot no longer stocks boxes it cannot serve.
|
||
The regression test asserts outbound unjams stay at zero *and* that genuine MEN|AT|WORK jams are
|
||
still cleared, so gutting the fallback would not pass it.
|
||
|
||
### Half the industries never asked for a car
|
||
|
||
Freight had not moved through two rounds of fixing the district, and this is why: **three of the six
|
||
industries were invisible when the bot chose what to put on a train.**
|
||
|
||
`wantedCars` consulted a hand-written `industry -> car type` switch that had drifted from the sheet.
|
||
It named `produceShed` and `oilRefinery` — neither is an industry — and omitted `freightHouse`,
|
||
`refinery` and `packingSheds`, which are. An industry it could not name returned null and was
|
||
skipped entirely, so it never requested a car. The Refinery is the only source of tank traffic, so
|
||
tank cars boarded a train 0.07 times a game and were **dropped by a crew zero times in 100 games**,
|
||
while 23 of 79 waiting loads sat at an industry that wanted one.
|
||
|
||
It now derives the commodities from `INDUSTRY_PROFILES`, which is the sheet. The switch is deleted
|
||
rather than corrected — a second copy of the mapping is the bug, not the values in it.
|
||
|
||
**A second, narrower collapse.** `facilityCarType` returned `carTypes[0]`, so the second commodity
|
||
of a two-commodity industry was unreachable: a Power Plant burns coal OR oil, a Grocer's Warehouse
|
||
receives dry goods OR perishables. `facilityCarTypes` (plural) now returns the full set, and the
|
||
bot's spotting and switching checks accept any of them.
|
||
|
||
Worth stating precisely: the engine's `WRONG_CAR_TYPE` gate was corrected to use the full set too,
|
||
but that changes nothing today — both two-commodity industries are inbound-only, so
|
||
`freightAgent.stockOutbound` rejects them before the commodity is examined. That fix is latent. The
|
||
measured gain is entirely the bot side.
|
||
|
||
| | facilities fixed | cars fixed |
|
||
| --- | --- | --- |
|
||
| revenue | 5.0 | **6.0** |
|
||
| wins | 1/100 | **5/100** |
|
||
| freight revenue | 1.3 | **2.6** |
|
||
| freight share of gross | 25% | **37%** |
|
||
| loads completed | 1.27 | **2.60** |
|
||
| tank cars dropped | **0.00** | **0.62** |
|
||
| reefers dropped | 0.04 | 0.67 |
|
||
|
||
Both regression tests were checked against the bug they guard: restoring the stale map fails the
|
||
tank assertion, and collapsing `carTypes` to its first entry fails the profile assertion.
|
||
|
||
**Still 70% of waiting loads have nothing spotted at all.** The commodity mix is right now; the
|
||
volume reaching the industry tracks is not. That is a routing question — which facility the crew
|
||
takes a car to — rather than a car-choice one.
|
||
|
||
### Putting the industries on the siding
|
||
|
||
The run-arounds were being built and the industries were somewhere else — facilities sitting on one
|
||
stayed at **0.00 a game** even at 91 districts in 100 with a closed loop. The cause was blunt:
|
||
facility placement was `options.find(i => i.placement !== undefined)`, the first legal square the
|
||
generator happened to list, unscored, while track laying had sixty lines of scoring beside it.
|
||
|
||
**Facilities are now scored**, on the thing that decides whether a crew can serve them at all: a
|
||
placement in line with a siding still being built becomes part of the loop itself (its own through
|
||
track is a segment), so the crew reaches it from either end and can pass its standing cars (§A.5).
|
||
Merely touching reachable track is worth less; the Running Track is a penalty, because a car left
|
||
standing there is hit by the next arrival (§11.2).
|
||
|
||
**A second bug surfaced immediately, and it is the interesting one.** Scoring facilities onto the
|
||
siding row sent run-arounds *down*, 91 games in 100 to 36 — the industry took the square and the
|
||
loop stopped closing around it. `runsAcross` tested the card's KIND ("a track straight running
|
||
east-west"), so an industry standing in the line read as a dead end and the run refused to extend
|
||
through it. It now asks the card's PORTS instead. A Facility carries its own rails (§11.2), and so
|
||
do the Office and a Limits sign; what matters is whether a port faces this way.
|
||
|
||
The reachability walk is now shared between track laying and facility placement rather than written
|
||
twice — two copies would eventually disagree about whether a district connects, which is the one
|
||
thing both decisions rest on.
|
||
|
||
| | before sidings | sidings fixed | facilities fixed |
|
||
| --- | --- | --- | --- |
|
||
| facilities on a run-around | 0.00 | 0.00 | **1.08** |
|
||
| games with a run-around | 0/100 | 91/100 | 71/100 |
|
||
| revenue | 3.2 | 4.1 | **5.0** |
|
||
| collisions | — | — | 0.4 (was 0.6) |
|
||
| track pieces spent | 19.5 | 16.1 | 13.6 |
|
||
|
||
Run-arounds fall from 91 to 71 because facilities now compete for the siding squares — which is the
|
||
trade being made deliberately: a loop with an industry on it is worth more than an empty one.
|
||
|
||
**Freight did not follow.** Loads completed 1.42 → 1.27 and freight's share of gross 31% → 25%; the
|
||
revenue gain is passengers and fewer collisions. Of facilities holding a load, 77% still have
|
||
nothing spotted at all. The industries are now reachable and the right cars still are not arriving —
|
||
which is the car-selection problem in TODO, untouched by any of this.
|
||
|
||
### The bot was building stubs, not sidings
|
||
|
||
**Measured first: 0 run-arounds in 100 games.** A run-around is the engine's own definition of a
|
||
useful siding (`track.ts`) — double-ended, both ends reaching the main, and §A.5's facing-point move
|
||
is impossible without one. Every district the bot built was dead-end stubs, 3.86 of them a game,
|
||
plus 2.89 cards below the main that reached nothing at all. Before trusting a zero the detector was
|
||
handed a run-around built on purpose and found it from both ends.
|
||
|
||
**Three bugs, all the same shape: scoring on local form without checking it reaches anything.**
|
||
|
||
- The +12 rule was commented "close the loop back up to the main: the run-around is complete" and
|
||
only tested that a neighbour ran east-west — never that the card above had a south port to join.
|
||
The siding terminated in an arc pointing north into empty space. It now requires a way up above,
|
||
asked of the engine's `hasPort` so the Office counts too; `divergesSouth` had looked for a turnout
|
||
and missed the one way down that is on every board. An arc's facing also decides what it meets —
|
||
`nw` joins west, `ne` joins east — so closing from the wrong side connected nothing.
|
||
- The east-west extension had no stopping condition, so the run went on past the last column it
|
||
could rejoin at, in 96 of 100 games. The loop then missed by one card.
|
||
- **`bestTrackLay` never declined.** This was the one that actually mattered, and the first two
|
||
fixes barely moved the overshoot without it: the function returned its best-scoring option
|
||
unconditionally, so once the useful squares were taken it kept laying track because track was
|
||
legal. Bonuses are now tracked apart from the distance score, and a piece that earns none is not
|
||
laid — the Stage falls through to playing a card instead.
|
||
|
||
Anchors also have to be reachable from the main now. A stranded east-west straight made both its
|
||
neighbours look like legal extensions, so a fragment joined to nothing grew in both directions.
|
||
|
||
| | before | after |
|
||
| --- | --- | --- |
|
||
| games with a run-around | **0/100** | **91/100** |
|
||
| track laid east of the last way up | 96/100 | **0/100** |
|
||
| track pieces spent | 19.5 | 16.1 |
|
||
| revenue | 3.2 | **4.1** |
|
||
| freight revenue | 2.6 | **3.8** |
|
||
| cars dropped | 11.6 | 20.9 |
|
||
| loads completed | 0.99 | 1.42 |
|
||
|
||
Two regression tests, both walking the district with the engine's own `exitsFrom` so they cannot
|
||
credit a connection §A.1 forbids: one asserts run-arounds get closed, the other that no track is
|
||
spent east of the last column with a way up.
|
||
|
||
**Still open.** Facilities sitting *on* a run-around: 0.00. The loops get built and the industries
|
||
are not on them, so the run-around is not yet paying for itself in freight — which is the next
|
||
thread, not a finished one.
|
||
|
||
### Freight, drawn where the work happens
|
||
|
||
**The load pipeline moved onto the card.** A load crosses green → `MEN | AT | WORK` → a spotted car,
|
||
and that journey *is* freight. It was drawn only in the side panel, so a Laborer action — the whole
|
||
of the freight game — changed nothing on the card the player was looking at. The squares now sit
|
||
under the track, which is where the printed cards put them and why they are printed at all. The
|
||
tooltip names the same thing in words: which square the load is on, and what the next Laborer action
|
||
does with it.
|
||
|
||
**A collision found while placing them.** `overpass` and `facingPointLocks` are placed `onCard`, so
|
||
they can land on a facility, and the enhancement label's baseline ran straight through the new
|
||
squares. The label moved into the gap between the crew tray and the pipeline; a test now asserts the
|
||
two do not overlap rather than trusting the two constants to stay apart.
|
||
|
||
### Teaching the bot to use a siding
|
||
|
||
**Nose coupling, which the rules had and the engine did not.** §A.3: "engines also have couplers on
|
||
the front end, so a train can pick cars up onto its nose". Coupling always appended to the back, so
|
||
which end cars landed on did not exist — and that is precisely what a run-around is for. Cars met
|
||
running FORWARD now couple in front, cars met BACKING couple behind, so the approach decides which
|
||
car is next off the tail:
|
||
|
||
running forward -> reefer, boxcar, hopper next off: hopper
|
||
backing up -> boxcar, hopper, reefer next off: reefer
|
||
|
||
The bot prefers a run-around to setting a car down, since it keeps the car — but only when the drop
|
||
can actually follow. Without that guard it ran the loop for its own sake (8 a game, 63 Moves), which
|
||
the shuttling regression correctly failed.
|
||
|
||
**A serious bug found on the way.** The `carsCoupled` reducer cleared **every card in the Office
|
||
Area**, not the ones the crew ran over — its own comment said "every card along the path" while the
|
||
code iterated the whole grid. One coupling anywhere deleted every standing car and every industry
|
||
track in the district, so loads worked over several Stages vanished when a crew picked up an
|
||
unrelated boxcar somewhere else. The event now names the cards it lifted from.
|
||
|
||
**A test replaced rather than relaxed.** "Under 60 Moves a game" started failing. That threshold was
|
||
calibrated when the crew coupled ~0.4 cars a game; it now couples ~8 and drops ~11, and a run-around
|
||
is *supposed* to cost several Moves. Counting Moves was always a proxy for aimlessness, so the test
|
||
now measures the thing itself — Moves per drop-or-coupling — which still fails when the bot is made
|
||
to wander.
|
||
|
||
| | before sidings | now |
|
||
| --- | --- | --- |
|
||
| revenue | 3.9 | 3.2 |
|
||
| freight | 0.9 | 1.0 |
|
||
| drops per game | 3.3 | **11.1** |
|
||
| couplings per game | ~0.4 | **7.9** |
|
||
|
||
Switching is transformed; revenue is not. The crew now does real work — roughly 4 Moves per
|
||
productive act, which is what running a loop costs — but that work is not yet converting into
|
||
Revenue. Where it goes next is an open question rather than a known fix.
|
||
|
||
---
|
||
|
||
## Board rendering, three-page site, curve geometry
|
||
|
||
Drawing the board as track, splitting the site, tooltips, and the curve fix.
|
||
|
||
### Board rendering
|
||
|
||
Two renderers in `src/sim/board-svg.ts`, chosen because they fail in opposite places:
|
||
|
||
- **Office Area** — the district stays a map. Cards on a grid with the rails drawn edge to edge, so
|
||
a join is rail meeting rail rather than two descriptions that happen to agree. The through rail
|
||
sits at a constant height on every card, which is the alignment the printed cards use.
|
||
- **The Division** — not a map but a queue of sections with hard capacities, so it is a dispatcher's
|
||
diagram: one line per track, `1 of 2 free` under each section, `no limit — trains queue` at the
|
||
Division Points.
|
||
|
||
Both are **self-contained** — no imports, no module-level helpers — because the playable app imports
|
||
them normally while the replay is a single HTML file with an inline script that cannot import
|
||
anything, and embeds them via `Function.toString()`. One implementation either way; a second copy
|
||
would eventually draw a different board from the same state.
|
||
|
||
Rails come from the engine's own `connectionsFor`, now exported. A drawn rail cannot claim a
|
||
connection the rules do not have — visible in that **Modifier cards draw no rails at all**.
|
||
|
||
Capacity, confirmed from the rules and now shown: Division Points unlimited, most Mainline cards 1,
|
||
Double Track and Uncontrolled Siding 2, Offices 1/2/3/4 by tier.
|
||
|
||
### Three pages
|
||
|
||
`index.html` is now a splash with two doors. The game moved to `play.html`; `replays.html` is a
|
||
directory.
|
||
|
||
**A replay is a save**, and a save is `{seed, history}`. The engine is deterministic and runs in the
|
||
browser, so re-submitting the same moves rebuilds the position exactly — **18 KB instead of 3.4 MB**,
|
||
about 190× smaller, small enough to email. A save that would describe an impossible position cannot
|
||
be replayed at all, because every step goes back through `applyIntent`; the viewer stops and says so
|
||
rather than showing a board the rules could not produce.
|
||
|
||
Static hosting cannot list a directory, so `replays/manifest.json` is generated at build time from
|
||
whatever is in `public/replays/`, with each file validated first.
|
||
|
||
### Tooltips
|
||
|
||
Reference detail is read once and printing it costs the space the board and action list need. A
|
||
single delegated tooltip (`src/web/tooltip.ts`) now carries card effects, action reasoning, and
|
||
facility state. Not the native `title`: that waits a second, cannot be styled, and never appears for
|
||
keyboard users.
|
||
|
||
### Curve geometry — the significant fix
|
||
|
||
A curve was modelled as a through track **plus** a diverging leg, which is a turnout. Consequences:
|
||
|
||
- Curves were **topologically identical duplicates of turnouts** — same connections, no distinction
|
||
beyond the sharp curve's 2-Move cost.
|
||
- Every diverging leg went south, so **no piece anywhere reached north** except an n-s straight,
|
||
which connects n↔s and nothing else.
|
||
- Therefore a district could only ever be a **vertical column**: no siding, no parallel track, no
|
||
run-around, no second turnout back to the main.
|
||
|
||
The printed cards (`docs/tracks.png`, rows 3–4) show a curve as a single arc from edge to edge with
|
||
no through track. A curve is now a **two-port arc**, rotatable to `ne`/`nw`/`se`/`sw`. A sharp curve
|
||
is geometrically identical and costs 2 Moves. Turnouts keep §A.1 unchanged — a two-port arc has no
|
||
third port, so the absent-edge rule cannot apply to it.
|
||
|
||
Verified through the engine, not the types: a crew leaves the main at a turnout, runs a siding
|
||
parallel, and rejoins at the far end.
|
||
|
||
### Measurements
|
||
|
||
| | revenue | freight | sidings built |
|
||
| --- | --- | --- | --- |
|
||
| before | 4.1 | 0.5 | impossible |
|
||
| geometry only | **3.9** | 0.9 | possible, not sought |
|
||
| bot builds sidings | 3.3 | **1.1** | **59/60 games** |
|
||
|
||
Freight more than doubled and the harness recorded **its first win**, but overall revenue is down
|
||
from geometry-alone and the bad tail worsened (−29 → −59).
|
||
|
||
Capping turnouts at one or two measured **worse** (revenue 2.5, freight 0.6) — more ways off the main
|
||
means more industries the crew can reach, and that beats a tidy Running Track. The uncapped version
|
||
stands, with that measurement recorded at the code.
|
||
|
||
**The bot builds sidings but does not exploit them.** It pays about 20 of its 26 track pieces for
|
||
them while its switching logic still sets out dead weight on a spur rather than planning a
|
||
run-around. That is the next piece of work and where the revenue should appear.
|
||
|
||
---
|
||
|
||
## Earlier commits
|
||
|
||
Recorded from memory of the work rather than written at the time; detail thins going back.
|
||
|
||
### `f00255c` — more fixes and tweaks
|
||
|
||
Card descriptions on every card in hand and every face-up slot, the three Local Operations options
|
||
explained, the objective and pace in the header, and rotations named in words rather than
|
||
"rotation 2". Build stamp added (version, git SHA, `-dirty` for an uncommitted tree) because nothing
|
||
tracked what was deployed. Extra X22 was **not** a bug — a per-diem train whose card calls for a
|
||
caboose and nothing else — but "loaded caboose" was.
|
||
|
||
### `d1f689d` — name the caboose, the train and the Running Track hazard
|
||
|
||
`maneuver.redFlags` was labelled "Red Flags on tray3": the earlier tray-id fix checked the history
|
||
log, and the action list was a surface it missed. An industry on the Running Track does not block
|
||
traffic (§11.2 gives it rails) but a car left standing there is hit by the next arrival (§10).
|
||
|
||
### `dd300ac` — fix caching, Limits placement, and explain the board better
|
||
|
||
The first deploy served a fresh `index.html` against a **cached** `main.js`, which threw
|
||
`missing element: target` and never started. Every module import now carries the build tag. The
|
||
Limits fix was half-done: the sign could move, but nothing forbade building past it, so one straight
|
||
at (0,2) produced `limits · Whistle Post · limits · straight · limits`.
|
||
|
||
### `2eca9de` — playable browser build
|
||
|
||
The solitaire game as a static site, proven to need no server: full games run with every Node global
|
||
replaced by a throwing stub. Train cards stopped accepting a board placement — seed 555 had offered
|
||
Extra X15 at six squares with six rotations, all identical.
|
||
|
||
### `160190d` — the bot's reasoning in the replay
|
||
|
||
Decision panel showing what the bot chose, why, and every option it passed over, with the reasons
|
||
reported by the bot itself rather than re-derived by the viewer.
|
||
|
||
### `d261ad7` — parking trains, freight deadlock, Whistle Post lock-in
|
||
|
||
Three faults found by measurement rather than failing tests. Trains parked because `destinationsFor`
|
||
computed reverse as `facing === 'e' ? 'w' : 'e'`, so a crew facing south reversed to east — a port a
|
||
north-south card does not have. Freight ran at a **3% load completion rate** because a load started
|
||
with no spotted car parks on WORK and locks the industry track, blocking the very car that would
|
||
clear it. Office density doubled after 25 of 100 games never drew a Depot and never escaped a
|
||
one-track Whistle Post.
|