v0.3.0 — playtest fixes, two new rules, and §7 enforced
This commit is contained in:
+265
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user