v0.3.0 — playtest fixes, two new rules, and §7 enforced

This commit is contained in:
Jesse
2026-08-12 21:08:28 -04:00
parent 6ead39c530
commit 1bf1e95058
38 changed files with 8514 additions and 5989 deletions
+265
View File
@@ -21,6 +21,271 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
## Unreleased
### Sharp curves are out of the deck
Eight cards, dealt zero copies. The only thing that made a sharp curve different from an ordinary one
was `moveCost: 2`, and nothing ever charged it — every switching move costs exactly 1, hard-coded — so
they were geometric duplicates taking eight draws from a deck the rebalance already considers too
diluted. `track.test.ts` even had a case called *"a sharp curve differs from a curve only in the Moves
it costs"* which asserted the two were identical, describing a difference that did not exist.
Jesse's call: take them out rather than build the Move cost, since a per-card movement cost is a
change to the Move model and the rebalance can wait. The rows stay in the catalogue at zero, exactly
as Poling does, so the design is still visible and the geometry still works if they are ever dealt
again. **Deck 243 → 235, track 104 → 96, solitaire 221 → 213.**
Four tests broke on the new deck composition and none of them was wrong about the game — all four
were resting on a single seed or a threshold that had drifted, so all four are now measured properly:
- **The oscillation detector ran on one seed.** Measured across sixteen: thirteen games show no
aimless shuttling at all and three reach a run of five. So it is a minority behaviour, not the
every-game waste the test was written for — it now asserts a rate (most games clean) plus a ceiling.
- **Interlocking reaches the board in 7/60 games, down from 15/60.** The bot has been pulled toward
other work by departure Revenue and spends its opening on the track it was dealt. Floor lowered to
match, and recorded as drift rather than quietly restated.
- **The action list reaches 9 buttons, up from 8** — all of them Switching, because the opening deal
grew districts from 17.9 to 20.3 cards and a crew has more squares it can legally reach. The list
getting longer for a good reason, not the cross-products that test was written to kill.
- **A test hoped seed 111 would deal an Office upgrade.** It now puts one in hand.
Bot after all of it: **7.0 mean, median 5.0, wins 5/200**, districts 18.8 cards.
### The special-train rules are enforced
All nine. They had been declared on `TrainRules` and read by nothing, so a Crack Limited could shunt
an industry and a Military train could be worked by Porters — the restrictions are what make a
special train special, and none of them applied. Each is checked where the act happens, and a LOCAL
CREW (`trainNumber: null`) is exempt from every one of them: it has no card, so it has no rules.
- **noSwitching** (both expresses, Light Engine, Campaign, Circus, Military) — moving, setting out
and sorting are all refused. All three are switching.
- **dropOnly** (X13 Appleseed) and **pickUpEmptiesOnly** (X22 Pee-Dee) — enforced on the MOVE, not on
a coupling action, because coupling is mandatory (§A.4): there is no running over cars and leaving
them, so the restriction has to bite on the move that would pick them up or it cannot bite at all.
- **oneFreightPerLocation** (trains 3/4 Express) — a budget per SQUARE rather than per turn, which is
what "one freight car at every location" says: the Express works a car here, moves on, works
another there. Dropping and picking up share the one budget, because the card says "drop OR pick
up". Kept in `turn.freightWorked`, keyed by tray and square, cleared with the turn.
- **noPassengerWork** (Military, Director's car) and **terminalsOnly** (Crack Limited) — Porters
refuse the train. `canBoard`/`canDetrain` now filter the trains at the Office rather than taking
any of them, so the rule actually bites; a new `passengerRefusal` works out whether a card is the
reason and says which, because "no train at the Office" was both wrong and unhelpful when there was
a train standing right there.
- **stopThenExpedite** (X17 Campaign) — the speeches happen at the first Office it reaches: that
arrival is an ordinary stop, and every arrival after it is expedited. A `speechMade` flag on the
tray, alongside `stopPointClaimed`, for the same reason — it is the TRAIN that stops, and an Extra
runs once.
**`coachStaysOnStationTrack` could not be built as intended, and the reason is worth recording.** The
chosen reading was "the coach may only be set out at the Office", but §A.4 refuses the Office square
to *every* drop — "the Office track is Operational Rail, but Rolling Stock may not be left there" —
so "only at the Office" and "nowhere at all" are the same rule. It is enforced as the effect that
survives: the Local may shunt its freight car around the district and may not abandon its coach doing
it. If the station track is meant to become a real place to leave a coach, that is a change to §A.4
rather than to this card; noted in `TODO.md`.
**`copiesNextScheduled` was deleted rather than implemented.** No train card ever carried it: a
Second Section is a Maneuver card played on a train due out, with its own intent
(`newTrain.secondSection`) that has worked all along. It was an unreachable second description of a
mechanic that already existed, and it had been sitting in the "nine rules read by nothing" count as
the one entry that needed removing.
**Cost, measured over 200 games: revenue 8.0 → 7.2 and wins 14/200 → 6/200.** Restrictions make the
game harder, which is the point — but it is worth knowing that enforcing §7 took back about a sixth
of what the departure-Revenue rule gave. The train tooltip no longer says "NOT YET ENFORCED BY THE
ENGINE"; it says what each rule does, because the restriction is the character of the card.
## 0.3.0 — 2026-08-12
### Two rule changes, both provisional, both measured
**The opening deal is now 3 track + 3 other, from two separately shuffled piles.** Track is shuffled
apart from the rest, each player is dealt three of each, and the leftover track is shuffled back in
for the rest of the game — the split is an opening-deal device only. A player opens holding six
against a hand limit of three, so the first turn is spent choosing which district they can afford;
§6.2 already permits "any or all" cards to be played in a turn and puts no cap on discards, so the
reduction can always be made and the engine needed no special first-turn case.
This is the answer to the run-around problem, which was measured five ways and is a SUPPLY problem:
a turnout and a matching-hand curve were in hand together on 0.2% of turns, about once every eight
games. **It worked, modestly** — run-arounds **4/60 → 7/60**, districts **17.9 → 20.3 cards** — with
revenue unmoved on its own (−0.1, inside noise). Nowhere near the 91/100 of the private-supply era,
so the supply question is softened rather than answered.
**One Revenue for every train that clears your section.** Paid when a train is highballed out of your
Office, once per train, and not again when it later runs off the end of the Division — that far
Division Point belongs to whoever is seated at it. A local switching crew has no train number and
earns nothing. Worth exactly **+5.40 a game**: departures run at 5.40 and each pays 1. The bot goes
**2.7 → 8.0 mean, median 0 → 6.5, wins 2/200 → 14/200**. Unlike freight and passengers it pays for
traffic the player does not have to work, which is the point — keeping the line clear is the
Superintendent's job and nothing paid for doing it well. It also makes the victory target live: 20
over 5 Days is now reachable largely on traffic.
**A regression that came with the deal, recorded rather than hidden.** Forced to shed on turn one,
the bot lays pieces it should discard: track butting a card that cannot accept it went from 7% to
15%. A player would simply discard the ones with nowhere good to go. The regression floor in
`sim.test.ts` was raised from 0.15 to 0.25 with the reasoning written in, and it is in `TODO.md` as
bot work for after the rebalance. It also means part of that district-size gain is padding.
**Found while fixing the tests this broke.** Three of them were passing on luck rather than checking
anything, and the deal change exposed all three at once: the commodity test asserted a tank car is
set out somewhere in 30 games when it happens in **3% of games**; the replay-sound test asserted six
cue kinds from a single seed when the bot couples in only **20%** of games and sets cars out in 43%;
and two web tests depended on a seed happening to deal a particular card. All four are now decisive —
pooled seeds, a sample sized to the base rate, or the card put in hand deliberately. Separately,
`moveTrain`'s branch for departing an Office straight onto a Division Point turns out to be
**unreachable**: `buildDivision` always lays `DP · Mainline · Office · Mainline · … · DP`, so an
Office is never adjacent to one. It is kept correct rather than deleted, and `setup.test.ts` now
asserts the flanking invariant that makes it dead.
All five published replays were re-recorded: the deal changes what every seed deals, so the previous
recordings died at 2 intents of ~350.
### Nine things playtesting found
Eight fixes and one new rule, all from one session at the table. Two of the nine turned out not to be
bugs at all, and saying why is most of the work in those.
**Left and right were on the wrong diagonal — for turnouts and for curves, the same way.** The engine
called a card `left` when a train entering at its points saw the diverging route leave to its
**right**: `{stem:'w', through:'e', diverge:'s'}` heads east, and south is the driver's right. Curves
inherited the same inversion, because a curve takes its hand from the turnout whose leg it continues.
Every track card a player has ever held was labelled as its mirror. **The artwork was never wrong** —
`board-svg.ts` draws rails from `connectionsFor` geometry alone — so this is words only, and the fix
flips the `hand` field on the `TRACK_CARDS` rows together with `CURVE_VARIANTS` and
`TURNOUT_VARIANTS`. The row ORDER is deliberately left alone: `setup.ts` builds the deck by walking
that array, so a row's position decides which physical card a seed deals, and reordering to look tidy
would silently re-deal every published replay. Verified by fingerprinting seed 1234's whole track
deck before and after — 101 cards, byte-identical, and the three replays that survived the rest of
this work prove it in practice.
**A turnout can now be laid on top of a card already down.** Reported as: a district can only hang off
a turnout, so a player who laid a straight along the main and then wanted to branch there had no move
at all — the piece had to have been a turnout when it went down. A turnout may now upgrade a straight
at any rotation, or a curve whose arc matches its own diverging leg. Both are strict port supersets of
what they replace, so an upgrade can never sever a join a neighbour relies on and needs no connection
test; the new leg may reach nothing, which is the point. Blocked on a standing car (`UPGRADE_OCCUPIED`)
and on a built Interlocking or Telegraph (`UPGRADE_ENHANCED`) — both about the card being in use rather
than its shape. The rule is stated in ARCS rather than hands so the flip above cannot reach it. The bot
found and played it 40 times across 40 games without being taught anything.
**A Depot showed three MEN | AT | WORK boxes it has no Laborer to work.** An Office is a Passenger
Facility and the sign is printed "For Freight Facilities" (§9.1), but every facility got a three-slot
array of nulls and the renderers loop it — the green and red rows either side *were* guarded and the
MAW row was not, in all three renderers. Fixed in the MODEL rather than the renderers: `menAtWork` is
now `null` on a passenger facility, which is what the "Freight only" comment on that field has claimed
all along. The type flushed out every reader, freight work is now refused on the shape of the facility
rather than on it happening to hold 0 Laborers, and a fourth renderer cannot reintroduce the bug. Two
more artifacts of the same "an Office is a Facility" modelling went with it: a Depot read
**SHIPS + RECEIVES**, an industry's flow word, and was drawn a siding square although its industry
track has length 0.
**The side panel painted inbound loads green.** Its shared box helper used one class for every filled
box, so the red row rendered green while the board SVG on the same screen drew it red — two views of
one card disagreeing about the colour code at the same moment. Green is outbound everywhere now, red
inbound, grey for the siding, which is a place rather than a direction.
**The card just drawn now sits first in the hand, badged NEW.** The engine pushes onto the end, which
with a wrapping row put the card you just turned over wherever the eye is least likely to be, among
two others that look exactly like it. Reversed in the DISPLAY — `actionMenu` for the play page and
`snapshot` for both replay viewers — and deliberately not in the engine: the bot iterates its hand to
generate options, so moving the stored order would reshuffle its tie-breaks and invalidate every
revenue figure in `TODO.md`. Confirmed: 2.8 before, 2.7 after, over 200 games.
**Two Ice Houses could be built in one district.** Industries have been barred from doubling up since
Q4, but a Modifier is a different card kind and had no such check at all. One of a kind per Office
Area now. Enhancements are deliberately left alone — an Interlocking is a plant at one junction, so a
second on another straight is a different installation. This is the change that killed a published
replay: `seed-4894942` had recorded a game that played two Waiting Areas, so it was a recording of
illegal play and has been re-recorded.
**NOT A BUG — "the Ice House added the laborer but not the outbound slot".** The card prints +1
*outbound*; a Grocer's Warehouse is `flow: 'inbound'`, so `allows.outbound` is false and the capacity
was being raised on a direction that can never be drawn or stocked. The laborer landed because
Laborers have no direction. An industry's printed flow stays absolute — no modifier turns a receiver
into a shipper — so the grant is now dropped rather than credited, and the reason is said out loud in
both directions: the card in hand names which of its printed hosts cannot use which half, and the
facility panel reports a suppressed bonus instead of quietly showing a number that did not move. The
same trap catches Truck Dock and Forklifts on a Grocer's, and Waiting Area, Restaurant and Hotel on a
Whistle Post. Two things fell out of this: `TrackCard.modifiers` was initialised everywhere and
appended nowhere, so the panel's "Modifier cards standing beside this industry" row had always been
empty; and a Waiting Area was lengthening the Office's `industryTrack`, which drew a Depot a siding to
spot cars on — the phantom siding above, arriving by another door. That one was found by rendering
12,000 frames of real games, not by a fixture, and it is why the smoke pass exists.
**An Interlocking on the board was a bare label.** It now carries hover text from the card catalogue,
and the one card nothing reads says so. **Seven of the ten resolve in play**: the dispatch chain
(Telegraph, Telephone, Radio) plus Interlocking, which holds an arrival at the Limits when the Office
is full; Yard Office, which diverts a coachless train; Small Yard, which re-orders a consist; and ABS
Signals, stored on the Mainline node rather than in `enhancements[]`. Facing Point Locks and the Water
Column are wired and read but answer opponent-directed cards a solitaire deck omits. **Overpass alone
has no code path anywhere**, and is the only one that carries the warning.
**Got this badly wrong first time, and it is worth recording how.** The table was filled in by
grepping for four helper function names — `dispatchBonus`, `hasDistrictEnhancement`,
`isProtectedFromDerail`, `watertowersRemovable` — and reading "no match" as "no implementation". But
Interlocking, Yard Office and Small Yard are read *by key* in `advance.ts` and `apply.ts`, and ABS
Signals through `node.absSignals`, so five of ten statuses were wrong and the tooltip told players
that four working cards did nothing — worse than the bare label it replaced. `enhancements.test.ts`
had covering tests for all four, passing, the whole time. The test written to guard the table made it
worse rather than catching it: it asserted `effect === 'live'` if and only if the rule carried a
`dispatchBonus`, which restates the assumption that produced the error instead of checking it. It now
asserts the three sets by name, and every row cites the file that reads it, because a status without a
citation beside it is a claim nobody checked.
### Three rules read back from the sheet
Jesse's follow-up after the above. Two were already right, which is worth recording so nobody
re-investigates them; one was a real gap that had been sitting in plain sight.
**Clearance asked about the next CARD, not the next Subdivision.** §8.1 is explicit — "if there is a
train in the next **Subdivision** moving towards the considered train, the considered train will not
depart" — and `evaluateClearance` only ever inspected `node.transits`, the single card being entered.
A train ran headlong into a Subdivision an opposing train was two cards deep in and was stopped only
on the Stage they actually met. `subdivisions()` had been sitting in `state.ts` for this the whole
time, called by nothing outside a test. Red Flags and ABS Signals now read the card the OTHER train
stands on rather than the card being entered; those were the same card while this only looked one
ahead, and the protection belongs where the train it protects actually is. Worth knowing how much
this bites: every Office starts as a Whistle Post, so the whole railroad is ONE Subdivision until
someone upgrades, and each upgrade to a Control Point splits one in two and buys capacity back. The
bot is unmoved (**2.7 both sides, 200 games**) because solitaire schedules only ~1.3 trains a game —
this is a rule that earns its keep with 2–5 players.
**Straight-placed enhancements replace the straight, so they no longer stack.** An Interlocking's
printed placement is "any Running Track Straight": it goes down IN PLACE OF the straight, and what
stands there afterwards is an Interlocking, not a straight carrying one. Nothing checked it — an
enhancement only pushes a string onto `enhancements[]` and leaves the geometry alone — so a single
straight could hold Interlocking, Telegraph and a Water Column at once. **The Telegraph → Telephone →
Radio chain is untouched and is not an exception**: Telephone prints "on Telegraph" and Radio "on
Telephone", so those target a named card rather than a straight, which is exactly why they still
stack. The printed placements settle it; `requiresOnSameCard` already enforced it.
**ALREADY CORRECT, twice.** Control Points and Subdivisions were modelled properly — `isControlPoint`
is false only for a Whistle Post, and `subdivisions()` splits between them — so only the clearance
*use* of them was missing. And §8.2's "moved immediately to the Office, where it stops for orders" was
already the behaviour: `arriveAtOffice` returns `moved`, and the caller adds the train to
`movedThisPhase`. The one exception is deliberate and recovered — Q3, an expedited train departs the
Stage it arrives, and gets a second `moveTrain` still subject to §8.1.
**A missing §8.1 condition, restored to the ruleset.** Jesse read the highball conditions back off the
source sheet and the first one — *"the train did not just initially arrive from a mainline card at the
Office this Stage"* — was absent from `rules-v0.2.md` entirely. The BEHAVIOUR was right, but only as a
side effect: the Mainline Phase visits each train once per Stage in numeric order, so a train that
spends its visit arriving has no visit left to depart with. Nothing anywhere stated the rule, which
makes it precisely the sort of property a later change to that loop breaks in silence. Now written
into §8.1 and pinned by tests, together with the observation that **this is the condition the printed
`Expedite` rule exists to override** — without it, `Expedite` was a special case with no general rule
to be special against, which is why Q1/Q3 found it so hard to place. All six conditions now have
coverage, including the Gap 2b carve-out: a train standing clear on Secondary Track in a Whistle Post
district must not hold up the Subdivision, which the new subdivision-wide scan had to be careful not
to break.
**Verification.** 438 tests (up from 416) and a clean `tsc`. Beyond the unit tests: the track deck
fingerprint before and after the flip; all published replays replayed end to end; the bot baseline
re-measured against a worktree at HEAD; and 40 real games rendered frame by frame through the same
functions the page calls — 12,169 frames, which is where the Waiting Area siding surfaced. Not done:
nobody has clicked through this in a browser.
## 0.2.0 — 2026-08-09
### The bot plays twice as well, and a rule it was never following