v0.8.3 — the engine half of the audit, and the deal 0.8.2 silently changed

Seven rules faults and one dealing fault, from a four-way code audit (engine, server,
client, tests) read against the code before anything was acted on. Each is pinned by a
test that failed first. CHANGELOG has the reasoning; this is the list.

THE DEAL. 0.8.2 put the Second Section card into the deck after its save check had run
and without a line in its notes. A deck one card larger shuffles differently from the same
seed, so every save on the test server refused at move 3 — the boot log shows thirteen of
thirteen — while the release notes said three would resume. `withSavedDeal` (was
`withSavedOpening`) now sets `secondSectionCard: false` for a config that predates the
setting, and the thirteen replay exactly as 0.8.2 described: three resume, ten refuse, the
same ten at the same moves.

THE RULES. `check` never tested that a switching tray was in the actor's own district, so
a rival's train could be shunted and the rival charged the Moves. Occupancy matched on
coordinates alone, so a rival's crew blocked your track. A Department draw that emptied the
deck duplicated the drawn card and destroyed the refill card. The unjam cleared the first
load rather than the one named. The collision floor could not fire in Stage 12. The
Expedite fault was charged once per clearance question rather than once per phase. A train
held at the Limits was only ever released by another arrival, never by a departure.

Docs: rules.md describes each as built (and no longer says an Expedited train departs at
Shift Change — that was v0.4.8's reading, corrected in v0.4.9's code and never in the
document); game-state.md's collision-floor note now matches the code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FrCWubm9GAftYCm2hWdKwK
This commit is contained in:
Jesse.Markowitz
2026-09-29 17:02:31 -04:00
co-authored by Claude Fable 5.1
parent 6f2a8dff09
commit 4d222a7eba
22 changed files with 627 additions and 83 deletions
+84
View File
@@ -19,6 +19,90 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
---
## 0.8.3 — 2026-09-29
The first of three releases from a code audit (engine, server, client, tests and hygiene, each read
by a separate reviewer and every finding re-verified against the code before it was acted on). This
one is the engine: seven rules faults and one dealing fault, each pinned by a test written to fail
first. **Every save on the test server was replayed under this build before release** — and that is
how the dealing fault was found, because under 0.8.2 none of them replayed at all.
### 0.8.2 stranded every game on the server, and said it stranded ten
The 0.8.2 notes said three of thirteen saves would resume. The server's own boot log, read for this
release, refused all thirteen at **move 3**, `CARD_NOT_IN_HAND` — including the game saved by 0.8.1.0
the notes had counted as safe. A refusal at move 3 is not a rule; it is a different deal.
The **Second Section card went into the deck in 0.8.2** (Q9, one copy — it had been defined and
never dealt), after that release's save check had been run and without a line in its notes. A deck
one card larger shuffles into a different order from the same seed, so every save older than the
card was replaying a different railroad from intent one. `withSavedOpening` could not help: it names
the opening, and the opening was not the problem.
Now `withSavedDeal`, and it names both. `secondSectionCard` is a house rule with no dial — a fact
about how a game was dealt, kept for the same reason `startingOffice` is — set false for a saved
config that predates the setting and true for everything dealt since. Under this build the thirteen
replay exactly as 0.8.2's notes described: **three resume, ten refuse, the same ten at the same moves
for the same rules.** The process rule this breaks is already written down ("say which way games in
progress go, every time"); what it adds is that the save check has to be the LAST thing before the
tag, not a thing done during the work.
### A player could switch a rival's train, and the rival paid for it
`check` resolved a switching tray with no seat test at all. The legal-move generator filtered trays
by seat; `check` did not; the server validates with `check` alone. Every district opens on the same
coordinates, so a destination legal for your own tray at (0,0) was "legal" for a rival's tray at
THEIR (0,0) — and the Moves came off the rival's turn, because `trayMoved` charges whoever sits in
the district the tray is in. All four switching intents now refuse a tray outside the actor's own
district with `NO_SUCH_TRAY`. Invisible in solitaire, which is why it lasted.
### A rival's crew blocked your own track
`occupancyFor().trayAt` matched on coordinates alone, so a crew standing at seat 0's (0,2) was
"another train standing here" at seat 1's (0,2) — a phantom that refused Moves and closed the Yard
Office walk in any game with more than one seat. It asks the seat now.
### Drawing the last card off a Department could duplicate it and destroy the refill
When a Department draw emptied the Home Office deck, the reshuffle was computed from the table
BEFORE the draw and the refill had been reduced: it swept up the card being drawn and missed the
refill card. The reducers then dealt the drawn card into the new deck while the refill card, moved
onto a pile the reshuffle wiped a moment later, left the game. Proven by counting — 47 cards in, 46
distinct out — and fixed by describing the sweep as the table will be after the events ahead of it.
### The unjam cleared the first load, not the one you named
`facilityUnjammed` carried no index, so the reducer cleared the FIRST load on MEN | AT | WORK and the
first car of the named type in a box. With an inbound tank load on MEN and a stranded outbound
hopper on WORK, unjamming the hopper deleted the tank load, sent a loaded hopper to the yard and
left the jam. The event carries the index now; events are regenerated on replay, so no save changes.
### The collision floor could not fire in Stage 12
`shiftChange` reset the Day's collision count at the rollover and only then judged it, so a breach
reached in the last Stage of a Day read as zero. The limits are judged on the Stage just played,
before the Day rolls over. `docs/architecture/game-state.md` had said the check was immediate; it
never was, and it now says what happens.
### The Expedite fault was charged once per question
The Mainline phase is re-entered from the top after every clearance, Yard Office and Red Flag
ruling, and the Q3 fault loop at the top ran unguarded — an Expedited train left on a siding was
fined once per interruption. Once per phase now. The rules document had also still described the
v0.4.8 reading of Expedite (departs at Shift Change), corrected in the code in v0.4.9; both passages
now describe the rule as built.
### A train held at the Limits was only ever released by another arrival
`arriveAtOffice` promised "held at the Limits until an A/D track frees up", and the only release
was inside `arriveAtOffice` for a DIFFERENT train. An Office that emptied by departures kept its
held train at the Limits for the rest of the game, invisible — no transit, no A/D track, no part in
the clearance check. At the end of every Mainline phase, held trains now take any free tracks in
the order they were held, and the history says a departure freed the track rather than naming an
arriving train that does not exist.
---
## 0.8.2 — 2026-09-23
A playtest read back against the save file, and the rules that came out of it. Nine questions were