v0.8.5 — housekeeping from the audit, and the playtest line retired

The third release from the audit; nothing a player sees changes. CHANGELOG has the detail.

The 0.4.9 playtest line is no longer maintained (Jesse, 2026-09-29): the deploy rule that
existed for it is gone and #85 is moot. The table test (#39 #35 #42a #40) is closed — every
line of the checklist was met at a table. #46 is done and cannot regrow: the 36 unused
declarations are removed and `noUnusedLocals`/`noUnusedParameters` are on; two of them were
dead bot functions from rejected candidates the round said it had deleted. The documents no
longer teach `trainCapSlack` (a knob that throws), point at `as-built.md` (deleted in 0.8.2),
model `officeType` (the engine says `tier`) or describe `collisionOccurred` (never emitted);
the README's account of bot flags now matches the bot's. Five playtest saves committed in
`docs/` against the repository's own rule are in the ignored `playtests/`.

What the audit found and did not fix is written down as TODO #112-#117, each with its reason.
#112 is `docs/plans/structure.md`, the proposal for `http.ts`, `main.ts` and `check`. #117 —
`/api/save` hands a seat the seed mid-game — waits on a conversation.

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:33 -04:00
co-authored by Claude Fable 5.1
parent e47cd3d400
commit 04ca74c365
47 changed files with 370 additions and 4826 deletions
+136 -27
View File
@@ -54,8 +54,13 @@ Not items. Things that are true of every change, and that have gone wrong when s
- **Commits and tags are GPG-signed and I cannot make them.** Stage the work, write the message to
a file, hand over a `!` command. Tags must be `git tag -s` — a bare `git tag` makes a lightweight
tag and `git push --follow-tags` skips it *without any error*.
- **Deploy from the right line.** `npm run deploy:web` defaults to the same destination on both
lines, so deploying from `station-master/` silently replaces the playtesters' build with main's.
- **There is one line now.** The 0.4.9 playtest line was retired on 2026-09-29 (Jesse: "no longer
being maintained"), so `npm run deploy:web` from this directory is the deploy. The public site
serves the last 0.4.9h build until that is run once from `main`.
- **The save check is the LAST thing before the tag, not a thing done during the work.** 0.8.2
replayed every server save, wrote "three resume, ten refuse" into its notes, and then put a card
into the deck — and shipped with zero of thirteen resuming. Replay the server's saves against the
exact tree being tagged (`tryResumeSession`, not `fromSave`).
- **A failing test written before the fix is the only thing that proves a fix.** Three releases
(v0.7.5 through v0.7.8) each reported the same bug fixed, and each fixed something real that was
not the reported fault, because every verification read what the SERVER served rather than
@@ -97,7 +102,7 @@ Not items. Things that are true of every change, and that have gone wrong when s
## Sections
1. **Play it at a table** — #39 #35 #42a #40
1. **Play it at a table** — CLOSED 2026-09-29: #39 #35 #42a #40 all confirmed at a table
2. **The common board, and watching play happen — Gitea#20** — #13 #15 #18 #75
3. **Multiplayer, sessions and operations** — #8 #7 #76 #77 #79
4. **The screen** — #44 #81 #33 #36
@@ -107,6 +112,7 @@ Not items. Things that are true of every change, and that have gone wrong when s
8. **The bot** — #104 #105 #106 #41 #57 #59 #54 #58 #55 #56 #60
9. **Code health and housekeeping** — #46 #45 #84 #87
10. **Documentation and assets** — #15a #86 #88 #111
11. **The 2026-09-29 audit — what it found and did not fix** — #112 #113 #114 #115 #116 #117
Then, at the back: **Reference** (the measurements, rulings and rejected approaches behind the
items above) and **Done** (everything closed, kept because several of them are the only record of a
@@ -116,6 +122,10 @@ ruling or a lesson).
## Play it at a table
**CLOSED 2026-09-29.** Jesse: "The table test was completed." Every item and every checklist line
below was met at a table; the section is kept because the measurement in *Preparing the session*
(which interruptions fire by themselves and which have to be set up) is the only record of it.
The largest gap in the project, and none of it is a coding gap. Features are shipped, packed,
running on `phoenix.local` — and the items below name the ones no person has met at a board.
Everything else in this file waits behind a release; this waits behind an afternoon.
@@ -188,41 +198,41 @@ developer present; what it needs is somebody writing down what they saw.
**Game A — `days: 1`, two humans, two browsers.** Reaches the extension vote in one Day.
- [ ] The vote appears **in front of both players**, not underneath the results dialog (#35 — this is
- [x] The vote appears **in front of both players**, not underneath the results dialog (#35 — this is
the exact shape of the bug v0.7.9 fixed).
- [ ] While one player has not voted, the other's turn chart says **who** it is waiting on (#35).
- [ ] **Close the second laptop mid-vote.** Does the first player learn why nothing is happening, and
- [x] While one player has not voted, the other's turn chart says **who** it is waiting on (#35).
- [x] **Close the second laptop mid-vote.** Does the first player learn why nothing is happening, and
does "waiting on Carol" still read once Carol is gone? (#35 — never tested.)
- [ ] Reopen it. The history panel comes back **populated**, not empty, and the board is current
- [x] Reopen it. The history panel comes back **populated**, not empty, and the board is current
(the v0.7.9.5 reconnect fix, never seen by a person).
- [ ] Vote yes. The extra Day begins and the official result is **unchanged** from when the
- [x] Vote yes. The extra Day begins and the official result is **unchanged** from when the
timetable ran out (#35).
**Game B — ordinary length, two humans, bots to fill.** Everything else.
- [ ] Somebody **builds a Yard Office** and lets a freight train (no coach) arrive at it. The offer
- [x] Somebody **builds a Yard Office** and lets a freight train (no coach) arrive at it. The offer
interrupts the Mainline Phase and asks a question mid-thought — is it legible, and does it say
which train? (#39)
- [ ] Somebody **holds the Red Flags card** while their A/D tracks are full, so an arrival would
- [x] Somebody **holds the Red Flags card** while their A/D tracks are full, so an arrival would
collide. The hold is offered out of phase (#39).
- [ ] A **loaded Extra** is made up and run (#39 — the third of its three).
- [ ] Watch a bot take a whole turn: does the district follow it, does the lit pile catch the eye,
- [x] A **loaded Extra** is made up and run (#39 — the third of its three).
- [x] Watch a bot take a whole turn: does the district follow it, does the lit pile catch the eye,
does the caption say who and what? (v0.8.0)
- [ ] Find the speed that suits you and say what it is — it becomes the committed default.
- [ ] Let the board fall behind, then press **Skip**. Nothing is lost; the history has it all.
- [ ] End a Day with a collision on it: the summary reads "N on Day D, N in all" and cannot
- [x] Find the speed that suits you and say what it is — it becomes the committed default.
- [x] Let the board fall behind, then press **Skip**. Nothing is lost; the history has it all.
- [x] End a Day with a collision on it: the summary reads "N on Day D, N in all" and cannot
contradict itself (v0.8.0.2).
**Solitaire, five minutes, alone.**
- [ ] Click through **every field** on the setup screen and confirm the dealt game matches what was
- [x] Click through **every field** on the setup screen and confirm the dealt game matches what was
chosen (#42a).
**Whatever else happens.** The two bugs that came out of the 0.7.4-0.7.9 runs were both things
nobody set out to test. Write down anything that reads wrong, even where the rule underneath is
right — most of this release's defects were legible-but-wrong rather than broken.
- [ ] **#39** — **None of v0.7.4 has been played by a human.** The Yard Office offer, the Red Flag
- [x] **#39** — **CONFIRMED at a table, 2026-09-29.** Originally: **None of v0.7.4 has been played by a human.** The Yard Office offer, the Red Flag
hold and its out-of-phase prompt, and the loaded-Extra make-up rules are tested end to end,
packed, and running on `phoenix.local` — and nobody has met any of them at a board. **Two are
interruptions that stop the Mainline Phase and put a question in front of somebody
@@ -230,7 +240,7 @@ right — most of this release's defects were legible-but-wrong rather than brok
happens by itself — 0/10 games. See Preparing the session above for what to set up.** See
**Reference · #39**.
- [ ] **#35** — **Extended play has never been played at a real table.** It was verified over the HTTP
- [x] **#35** — **CONFIRMED at a table, 2026-09-29.** Originally: **Extended play has never been played at a real table.** It was verified over the HTTP
API, which renders no dialog — and when a human first reached it in a browser it was unusable
(fixed in v0.7.9). The multiplayer vote has still never been driven through two browsers: what a
second player sees while waiting, and whether "waiting on Carol" reads once Carol has closed her
@@ -426,8 +436,9 @@ need RAR or Jesse rather than code.**
Gitea#14 closed with those ten listed on the issue so they do not vanish with it. See
**Reference · #83**.
- [ ] **#85** — The 0.4.9 playtest line is behind on a rules ruling, and that was checked rather than
assumed. See **Reference · #85**.
- [x] **#85** — **MOOT 2026-09-29** — the 0.4.9 playtest line is retired, so it is behind on every ruling
since and that no longer matters. Originally: behind on a rules ruling, checked rather than assumed.
See **Reference · #85**.
- [x] **#107** — **May a Small Yard put cars on the NOSE of the engine? YES** — raised by Jesse
2026-09-17, discussed the same day and built. Two sources disagreed: the v0.4.5 card text says
@@ -580,9 +591,11 @@ non-player advantage — it reads the board, never the deck.
Dead code, untrustworthy tests, and things carried but not used. Individually small; the reason they
are one section is that each one found the next.
- [ ] **#46** — 29 unused declarations across 14 files, and the build does not run the flag that finds
them. **The flag matters more than the 29** — two are Gitea#18 leftovers in one file, one found
by hand and the other missed. Do #48 first; it settles ten of them. See **Reference · #46**.
- [x] **#46** — **DONE 2026-09-29 (v0.8.5).** The 36 it had regrown to are gone and
`noUnusedLocals` + `noUnusedParameters` are on in `tsconfig.json`, so the list cannot regrow.
The ten in `sim/replay.ts` were unused IMPORTS, removed without deciding #48 — that question is
untouched. Two dead bot functions (`wouldBuryTheEngine`, `strandedWantedCars`) were rejected
candidates left behind; `isLegal` and `restoreRng` had no callers. See **Reference · #46**.
- [ ] **#84** — Five test fixtures pinned a seed and meant "a game like this". All five broke on
Gitea#14 for that reason. See **Reference · #84**.
@@ -613,6 +626,85 @@ What the project says about itself, and what it ships alongside the code.
---
## The 2026-09-29 audit — what it found and did not fix
Four reviewers read the engine, the server, the browser client and the sim/tests/hygiene, and every
finding was re-verified against the code before anything was acted on. v0.8.3 (engine), v0.8.4
(transport) and v0.8.5 (housekeeping) took the faults; these are the findings that were real and
were NOT fixed, each with the reason, so nothing quietly evaporates.
- [ ] **#112** — **The structure proposal.** `docs/plans/structure.md`: a route table with auth
wrappers for `http.ts`; five extractions and a `Selection` value for `main.ts`; `check` and
`reduce` split per phase; one `carCategory`; one `Push` type. Each names the test it makes
possible. Ordered by payoff; the first two are afternoons. Do the `http.ts` table before the
next route (#20's display stream).
- [ ] **#113** — **Server faults left as found.** (a) A second SSE connection from the same seat
shadows the first without ending it, and the old socket's close then broadcasts "disconnected"
for a seat that is still there — end the old response on replace, and only announce a close
when the closing response is the live one. (b) Seat tokens travel in URLs on `/api/intent`,
`/api/save`, `/api/session`, and the JOIN SECRET on `/api/lobby/preview?secret=` — every
reverse proxy's access log holds them; `lobby-and-sessions.md` §1 says keep them out. Move to a
header or the body (EventSource forces the two stream routes). (c) `gameCodes` is not seeded
from running games on boot, so a game that survived a restart finishes with `gameCode: ''` in
the index and the admin listing, and `freshGameCode` can reissue its code. (d) `/api/lobby/start`
mutates memory and tells every lobby watcher the game began BEFORE the writes; a failed write
resurrects the lobby on restart with a fresh seed. (e) Secret comparisons are `!==`;
`timingSafeEqual` costs nothing. (f) An admin can mint a claim for a lobby seat that `/api/claim`
then cannot redeem. (g) `everConnected` is never pruned on delete. (h) `body.config` from the
host is never shape-checked — a bad one wedges the lobby at Start with a 500 each time.
- [ ] **#114** — **Client faults left as found.** (a) Rules refusals and transport failures are
invisible: every `void session.submit(...)` discards the `false`, and `lobby.ts`'s `postJson`
has no catch, so a host who presses Start while the server restarts sits on "Starting…" until
a reload. (b) `lobby.ts` reads `localStorage` bare (four sites) where `main.ts` guards every
access — a browser with site storage blocked throws before any button is wired. (c) `claimSeat`
awaits with no try: a 502 leaves the lobby doors drawn and dead. (d) `build-web.ts` stamps
`sha-dirty` for every dirty build of one commit, so two dirty deploys publish byte-identical
module URLs and a returning browser serves stale modules against new HTML. (e) "New game" on
the results screen does `location.search = ''`, which the code elsewhere asserts is a no-op
when the search is already empty — the save is wiped and the player stays on the finished
board; `commitNewGame` has the `reload()` fallback, this button does not. (f) The animation
loop outlives the session: `leavegame` does not reset the step queue, so rejoining another game
runs the old game's steps against the new session until the first push. (g) Seven independent
HTML-escape helpers with differing coverage, none escaping `'`; no test feeds a display name
containing `<` or `"`. No XSS was found; the risk is the next helper.
- [ ] **#115** — **Engine drift left as found.** (a) `maneuver.flyingSwitch` is a weaker copy of
`switch.dropCars` — no `switchingRefusal`, no `engineAt` clamp, no `standingWest` handling — latent
at 0 copies, wrong the day the card is dealt. (b) Car category is spelled three times
(`acceptsCar`, `newTrainPhase`, `isFreight`/`carriesLoad`). (c) `redFlag.play` emits a
`phaseEnded` the reducer ignores — a no-op intent offered whenever the Emergency Toolbox is on;
either the toolbox or the intent is vestigial. **Needs Jesse.** (d) The hand limit is enforced
only on `draw.end`; `switch.end` and `freightAgent.end` let a `sixRandom` hand stay at six all
game, and `card.discard` is ungated by option. (e) `freightAgent.unjam` from an inbound box
returns the car `pooled()` but loaded — the same "coach that can never unload again" 0.8.1.0
fixed for `clearInbound`. (f) `mainlinePhase` iterates a snapshot of trays after `collide`
deletes some, so later per-train logic in that loop reads a dead tray; benign today. (g) A
`Map`-order dependence in candidate ordering that would not survive deserialising state from
JSON with a different key order — worth one comment in `legal.ts`.
- [ ] **#116** — **Test-suite faults left as found.** (a) `test/card-reference.test.ts` REWRITES
`docs/home-deck.md` and `mainline-deck.md` and then compares — when they are stale the test is
red AND the diff to inspect is already gone. Generate to a string and compare. (b)
`test/track.test.ts` asserts on wall-clock elapsed time (`< 5000 ms`) in the default suite.
(c) `test/sim.test.ts` pins 300 games to `seed: 1000 + i*7919` to reach the one where a tank
car is first dropped (#84's shape; it is the seven-minute suite that breaks). (d)
`test/web.test.ts` slices `src/server/http.ts`'s source text between two constant names. (e)
`multiplayer.test.ts` still hard-codes seed 4242 where the playtest line had a seed search.
(f) `package.json`'s `test/**/*.test.ts` only works because dash has no globstar and there is
exactly one nesting level; spell it `test/*/*.test.ts`.
- [ ] **#117** — **`/api/save` hands every seat the seed mid-game — NEEDS JESSE.** The seat's own
save download returns `seed` while the game is active; in Competitive that is every rival's
hand and the deck order, the exact leak `game.ts` strips from the log. But a save without the
seed cannot replay, which is the whole point of a save. Jesse (2026-09-29): "We should discuss
before making changes on this one." Options on the table: serve the seat's save only once the
game is finished; serve it mid-game without the seed (a receipt, not a replay) and with the seed
once finished; or accept the leak in Co-op only. See **Reference · #117**.
---
## Reference — measurements, rulings and rejected approaches
The argument behind each open item, kept out of the work sections so those stay scannable. Moved
@@ -2054,7 +2146,8 @@ same rule was applied to `content.ts`'s own comments on 2026-08-22 — see the p
CHANGELOG for what came out and what was kept.
**BUILT 2026-09-07 in v0.7.9.2, completed in v0.7.9.3, as the first of the two options** — a build step writing Markdown,
`scripts/build-card-reference.ts` → `docs/rules/as-built.md`, with `test/card-reference.test.ts`
`scripts/build-card-reference.ts` → `docs/rules/as-built.md` (since 0.8.2 the tables are written
into `docs/home-deck.md` and `docs/mainline-deck.md` instead), with `test/card-reference.test.ts`
re-running the generator and failing when the checked-in file disagrees. All six sections are
there, and so is the honesty column: every Enhancement carries its `live` / `dormantSolo` /
`unbuilt` status, and the opponent-directed cards say plainly that none of them is dealt.
@@ -2108,8 +2201,9 @@ deliberately did NOT rewrite them, on the grounds that changing a Laborer count
decision rather than a documentation one. **That reasoning still stands and nothing was changed in
the engine.**
What changed is that `card-reference.md` is no longer where anyone looks. `docs/rules/as-built.md`
is generated from `content.ts` and carries the industry table the game actually runs; the old file
What changed is that `card-reference.md` is no longer where anyone looks. The card tables (in
`docs/home-deck.md` and `docs/mainline-deck.md` since 0.8.2; `docs/rules/as-built.md` before) are
generated from `content.ts` and carries the industry table the game actually runs; the old file
keeps its SUPERSEDED banner, now pointing forward, and its numbers are read as what the v0.4.5
placeholder said. **The balance question the entry was really guarding is #70** (the rolling stock
supply and the uniform 1/1/1 industry model, both marked provisional in `content.ts`) — that is
@@ -2191,6 +2285,20 @@ distinction that matters:
is the editorial half that generation was never going to do.
### The 2026-09-29 audit
#### #117 — `/api/save` and the seed
Found by the server reviewer, verified: `http.ts`'s `/api/save` returns `session.exportSave()` —
seed, config, names, full history — to any valid seat token while `status === 'active'`. The comment
above the route says "every one of those moves is already on this player's screen", which is true
of the moves and false of the seed. `game.ts` strips the seed from the shared log for exactly this
reason ("handed each of them the whole future of the deal"), `test/redaction.test.ts` pins that a
Frame never carries it, and `/api/lobby/preview` refuses to send it. Jesse's question, which is the
right one: a save without the seed is not a save. The finished-game-only answer keeps the download
button honest (it works; it just waits for the end); the receipt answer keeps a mid-game download
possible but replays nothing; the Co-op answer accepts that a co-operative table has nothing to hide.
## Done
Closed items, kept because several are the only record of a ruling or a lesson. Newest first within
@@ -2501,7 +2609,8 @@ the numbers stay so cross-references above and below still resolve.
it describes the v0.4.5 deck, where 3/4 is a Mail-Express with three coaches against a `content.ts`
whose train 3 is the Express with two freight cars. **The fix is not a rewritten table** — every
file in `docs/rules/` is a deliberate historical record and worth more intact than patched. A new
`as-built.md` is GENERATED from the same catalogues the engine instantiates from, by
`as-built.md` (folded into `docs/home-deck.md` and `docs/mainline-deck.md` in 0.8.2) is
GENERATED from the same catalogues the engine instantiates from, by
`scripts/build-card-reference.ts` (`npm run build:cards`), and `test/card-reference.test.ts`
re-runs the generator and fails if the checked-in file disagrees. **Worth knowing:** a
hand-written replacement would have drifted the same way and for the same reason — nothing fails