Track becomes a deck card, Departments become decks, and trains must be made up to leave, industry restrictions enforced, update display and add a new game button

This commit is contained in:
Jesse
2026-08-05 08:28:22 -04:00
parent 2d348e1ecf
commit 9c76b1a5e2
28 changed files with 1927 additions and 394 deletions
+286
View File
@@ -10,6 +10,292 @@ The target is 20 Revenue over 5 Days.
## Unreleased
### 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