Phases 0-1 shipped in v0.4.0 (seat/identity split, per-player turn state, the Session boundary). This lands Phase 2 (server core, one game, no lobby) and Phase 3 (persistence and resumption) per docs/architecture/multiplayer.md §12. Phases 4-6 (lobby/reconnection, the 22 opponent-directed cards, StartOS packaging) are still ahead. Phase 2: src/server/session.ts hosts a game in pure logic (no sockets) on top of game.ts's existing Game/submit/currentActor/actionMenu; it verifies seat === currentActor(game) itself before calling submit, since submit() trusts its caller and a server can't. src/server/http.ts and index.ts add POST /api/game, GET /api/stream (SSE, per-seat), POST /api/intent, and static serving of dist/. src/sim/frame-delta.ts is a purpose-built per-seat board delta for one live push at a time. Found and fixed along the way: actionMenu(game, seat) only used seat for the hand field, so a server computing every connected seat's Menu would have handed the acting player's legal moves to a waiting seat. Verified with a live end-to-end smoke test (2-player game, two SSE streams, a rejected intent from the wrong seat, an idempotent resend) plus test/server/session.test.ts and test/redaction.test.ts. Not verified: an actual browser (none available in this environment). Phase 3: src/server/persistence.ts writes game.json and turn-timings.json, atomic-rewrite-then- rename. game.ts gained fromMultiplayerSave, fixing a narration-attribution bug found while testing it (fromSave's replay loop drops the actor argument, invisible in solitaire, unreadable the moment there's more than one seat — fromSave itself still has this gap, deliberately untouched). Verified live: server killed and restarted mid-game, both seats reconnected exactly where they left off. Two rules bugs found while building this: the New Train phase never implemented its car-placement round (every car of every train was placed by the Superintendent alone, in every mode, all along — now reads the round position off tray.consist.length); and victory conditions are now one shared, configurable GameConfig set across solitaire/competitive/coop instead of a fixed length lookup and a dead firstToTarget condition. Also folds in the three fixes already released on the patch line as v0.4.9b/c/d: a switching train's crew badge failing to draw once it left the Office square, an unload that always took the westmost car regardless of which was picked, and a legal decision that could render with zero buttons. docs/testing/0.5.0-test-plan.md and three reported-bug save files (docs/station-master-seed*.json) included for reproducibility. tools/jitsi-harness/ deliberately left untracked — unrelated side-project work, not part of this release. 635 tests, 0 failures.
953 lines
79 KiB
Markdown
953 lines
79 KiB
Markdown
# To do
|
||
|
||
Things worth coming back to. Anything noted here should either get done or get an explicit decision
|
||
not to — the point is that nothing quietly evaporates.
|
||
|
||
Grouped by what kind of work it is — Next, Replay/Save Games, Bot Performance, Play Balance,
|
||
Multiplayer, Rules Questions, Other — and ordered within each by how much it is currently costing us.
|
||
Reorganized 2026-08-20 from a flat list; nothing below changed, only where it lives. Two duplicate
|
||
entries (Heavy Grade orientation, the Local's coach) were merged into one each, and the industry-table
|
||
item that had been sitting in a "these are all done" section without actually being done was moved out
|
||
to Rules Questions.
|
||
|
||
---
|
||
|
||
## Next
|
||
|
||
Queued from the 2026-08-20 multiplayer planning session (reasoning in Multiplayer below), in order:
|
||
|
||
1. ~~**Fix the New Train phase car-placement round**~~ — done, see Multiplayer below.
|
||
2. ~~**Unify victory conditions across solitaire, competitive and coop**~~ — done, see Multiplayer
|
||
below.
|
||
3. ~~**Phase 2 of `docs/architecture/multiplayer.md` — server core**~~ — done, see Multiplayer below.
|
||
|
||
Nothing else queued at the moment.
|
||
|
||
---
|
||
|
||
## Replay / Save Games
|
||
|
||
The replay viewer, the save format, and how a game gets shared.
|
||
|
||
- [ ] **INVESTIGATE: how would a player publish a replay so other people can watch it?** Today
|
||
"Save replay" downloads a JSON file to the player's own machine, and the only way it reaches
|
||
the site is by sending it to Jesse to drop into `public/replays/` and redeploy. The question is
|
||
what a self-service version would look like.
|
||
|
||
**The constraint.** The site is fully static — `dist/` is uploaded to File Browser and Start9
|
||
Pages serves the folder — and the replay list is a build-time `manifest.json` because static
|
||
hosting cannot list a directory. So publishing needs something that accepts a write.
|
||
|
||
**The one measurement that matters:** a full 5-Day game is **451–1017 bytes** compressed
|
||
(brotli), about **600–1150 characters** base64. A save is the seed plus the intents and the
|
||
engine recomputes the board, so a whole game fits in a URL.
|
||
|
||
Four shapes, roughly costed:
|
||
1. **Share by link, no server (~1–2 hours).** Put the compressed save in the URL fragment
|
||
(`replays.html#s=…`); "Share replay" copies a link and anyone opening it watches the game.
|
||
The viewer already parses saves and already has a file-open path, so this is compression, a
|
||
hash reader and a copy button. The fragment never reaches the host. It is a link rather than
|
||
a gallery: nobody discovers a game they were not sent.
|
||
2. **A write endpoint (a day or two, and it is a service).** Accepts a POST, validates the save
|
||
by replaying it through the engine — `save-replay.ts` already does exactly that check —
|
||
writes the file and regenerates the manifest. The work is the surround: auth or rate
|
||
limiting, abuse handling for a public write, CORS, and a deploy story. It also ends "static
|
||
hosting is all this needs", which has been load-bearing.
|
||
3. **Browser writes to File Browser directly — rejected.** It needs FB credentials in a static
|
||
page, so anyone viewing source gets write access to the whole File Browser, and the manifest
|
||
would need a read-modify-write from the browser that loses a save when two people publish at
|
||
once.
|
||
4. **Curated, manual (zero code).** What happens today, and it composes with (1): players send
|
||
links, Jesse publishes the good ones.
|
||
|
||
**The question behind the question is whether a gallery of strangers' games is wanted on a
|
||
personal StartOS box at all.** If it is, (1) is the piece (2) would need anyway, so it is the
|
||
right thing to build first either way.
|
||
|
||
- [ ] **`fromSave`'s replayed narration loses "Player X" attribution — found 2026-08-20 building
|
||
multiplayer Phase 3, not fixed there.** `fromSave`'s loop (`game.ts`) calls `record(game,
|
||
result.events)` without the `actor` argument `submit()` always passes it (`game.ts`'s own
|
||
`record(game, events, actor)` — `actor` is what turns "Chose to draw a card" into "Player X
|
||
chose to draw a card"). So a restored save, an undone game (`undo` rebuilds via `fromSave`
|
||
internally), or a replayed one all lose attribution on every line — invisible in solitaire
|
||
because nothing ever compares a `fromSave`-built log against a live-played one (the one test
|
||
that compares logs, `test/web.test.ts`'s "leaves nothing in the log describing a move that was
|
||
taken back", compares `undo`'s `fromSave`-built log against ANOTHER `fromSave`-built log, so
|
||
the missing attribution cancels out both sides), but it would read as broken the moment more
|
||
than one seat's history is on screen at once — exactly what the replay viewer and any
|
||
multiplayer post-game replay (D20) need to get right. Fixed in `fromMultiplayerSave`
|
||
(multiplayer's version of this function, added for Phase 3) by passing `actor` through; not
|
||
touched in `fromSave` itself since it's used far more widely (undo, save/restore, the replay
|
||
viewer) and deserves its own careful look rather than a fix bundled into an unrelated change.
|
||
|
||
- [ ] **Review the standalone replay against the site's replay viewer.** `node src/sim/replay.ts
|
||
--seed 1234 --out replay.html` writes a self-contained HTML file; the site instead reads JSON
|
||
saves from `public/replays/`. Nothing links to the standalone one and its output is gitignored,
|
||
so it is a developer tool that happens to look like a product feature. It carries two panels
|
||
the site viewer does not — the bot's decision trace ("what it chose, why, and what it passed
|
||
over") and the timetable — which is debugging material rather than something a player wants.
|
||
Decide: fold the decision trace into the JSON viewer and delete the standalone, or keep it and
|
||
accept that it is a tool. No action for now.
|
||
|
||
- [ ] **The yards are shown on the play page but not in either replay viewer.** The Frame carries
|
||
them, so it is a rendering job, not a modelling one.
|
||
|
||
- [ ] **Undo is unlimited step-back, and that is a decision to revisit.** The save is the seed plus
|
||
the intents, so `undo()` replays without the last one and can walk all the way to the deal. The
|
||
RNG advances with the replay, so the same play re-rolls the same 1D12 — you cannot undo your
|
||
way to a better die. But you CAN see a train's departure Stage and then spend the turn
|
||
differently, which is an ordinary solitaire take-back and also a real information leak. Options
|
||
if it starts to feel like cheating: make the Stage boundary a commit point, or cap the depth at
|
||
the current Stage. Deliberately left open until it has been played with. Multiplayer gets
|
||
nothing until there is a proposal/agreement flow — undo there is a table decision, not a
|
||
button.
|
||
|
||
- [ ] **The 5 MB replay size limit is arbitrary.** Invented, not a browser constraint. It has earned
|
||
its place — it caught a 5.2 MB payload that turned out to be the whole grid re-serialised every
|
||
frame — but the number itself deserves a reason.
|
||
|
||
- [ ] **Save/restore is not version-aware.** A save from an older ruleset stops replaying rather than
|
||
failing loudly, which is the safe direction but says little about what changed. **This has now
|
||
bitten once**: both published replays were dead — one got 42 intents into 360, the other 4 of
|
||
338 — and nothing said so; they simply ended early and looked like short games. A save should
|
||
carry a ruleset stamp and the page should say "this replay was recorded under an older
|
||
ruleset and stops at Stage N" rather than presenting a truncated game as a whole one.
|
||
|
||
---
|
||
|
||
## Bot Performance
|
||
|
||
What the developer bot can and cannot yet do, measured. Every revenue figure below measured before
|
||
v0.4.7 is low by roughly half a point — see the stub-industry entry — and the rebalance pass should
|
||
not read that drop as a deck problem.
|
||
|
||
- [ ] **THE BOT DOES NOT KNOW TO BRING AN EXPEDITED TRAIN BACK TO THE STATION — new in v0.4.9.**
|
||
The `expediteFault` mechanic (§7, Q3) charges 1 Revenue every Mainline Phase an expedited train
|
||
is left off the Office square, and the bot has no heuristic that accounts for it: measured over
|
||
30 fresh games, one left Train 4 (3/4 Express) parked on Secondary Track from Day 3 Stage 10 to
|
||
the end of the game, drawing the fault **26 times**. Not an engine bug — the mechanism fires
|
||
exactly as designed — but a clear next bot heuristic: prefer ending a switching turn with any
|
||
expedited crew back on the Office square, at least once it has finished the work it went out for.
|
||
- [ ] **THE BOT CANNOT SPOT A CAR AT A STUB INDUSTRY, and the cut-ordering rules made that visible.**
|
||
Coupling is mandatory on your own square now (v0.4.7), so a crew that sets a car out *between
|
||
itself and the only way out* picks it straight back up. At a stub industry that is every
|
||
set-out the bot makes: its trains run engine-first with all four cars behind, so the tail cut
|
||
always lands on the exit side. The correct play is §A.5's **facing point** move — couple the car
|
||
onto the nose, shove it into the stub, set out off the nose, back away — which is the same
|
||
cross-turn planning already recorded as out of reach of any bot in "THE RUN-AROUND IS OUT OF
|
||
REACH OF ANY BOT" below.
|
||
|
||
Measured over 200 paired seeds: **-0.55 revenue** (t = -3.63) and freight revenue 1.11 → 0.56.
|
||
Filtering self-recoupling moves out of the bot's options took recoupling from **625 of 1,029
|
||
set-outs in 60 games to 101 of 677**, and all 101 that remain are this case. Nothing is broken —
|
||
the game models the difficulty correctly and the bot cannot yet play it — but **every revenue
|
||
figure in this file measured before v0.4.7 is now low by roughly half a point** and the rebalance
|
||
pass should not read the drop as a deck problem.
|
||
- [ ] **BOT DRIFT ACROSS THIS RELEASE — four measurements, all for the rebalance pass.** Recorded
|
||
together so the pattern is visible rather than four relaxed thresholds nobody adds up:
|
||
- **Switching work down ~16%, 1.76 → 1.48 productive acts a game** (400 games), because an
|
||
expedited train now stands at the Office for a Stage instead of passing straight through, and
|
||
a train on the A/D track and the Office square is in the crew's way. That is the change doing
|
||
its job rather than a fault — but it is drift. Collisions also went 0.05 → 0.06 and the worst
|
||
game went −3 → −9, same cause: the Office fills up. **Separately, the `work > 2` floor that
|
||
caught this had never actually been met** — it read 2.16 at 150 games and 1.76 at 400, so it
|
||
was passing on which seeds the sample happened to include. Now 400 games and a floor of 1.2.
|
||
- **Track laid badly, 7% → 15%** of pieces butting a card that cannot accept them. Forced to
|
||
shed on turn one, the bot would rather lay a piece than discard it; a player would discard
|
||
the ones with nowhere good to go. It also means the district-size gain from the new deal is
|
||
partly padding rather than useful railroad.
|
||
- **Interlocking placed, 15/60 → 7/60 games.** Departure Revenue pulls the bot toward other
|
||
work and it spends its opening on the track it was dealt.
|
||
- **Aimless shuttling in 3 games of 16** — thirteen are clean, so this is a minority behaviour
|
||
rather than the every-game waste the detector was written for.
|
||
Each floor was moved to match what is measured, with the reasoning written into the test. None
|
||
is a crisis on its own; together they say the bot spends its openings worse than it did.
|
||
- [ ] **The bot was partly living off an illegal placement.** Barring curves from the Running Track
|
||
(they have no east-west road and dead-end the main) cost it districts 28.0 → 19.7 cards and
|
||
revenue ~2.0 → 0.8. It has no plan for where a curve should go once the easy square is gone.
|
||
Same root cause as "the bot cannot get a crew next to an industry" and "THE RUN-AROUND IS OUT
|
||
OF REACH OF ANY BOT" below; fix them together, after the rebalance.
|
||
- [ ] **THE BOT'S PRIORITIES ARE NOT THE PROBLEM — measured.** Ten heuristic variations, each paired
|
||
over 400+ seeds. Every reordering of what the bot prefers came out inside the noise; the only
|
||
thing that moved revenue was refusing to schedule a train the Office cannot hold
|
||
(**+1.09 ± 0.16, t = 6.79** at 1600 seeds, revenue 1.19 → 2.28). Notable failures, all
|
||
instructive:
|
||
- **Refusing to bury the engine costs more than it saves** (−0.35, t = −2.98). It works —
|
||
burial falls from 8.4 decisions a game to 0.03 — and freight halves with it, because
|
||
coupling is mandatory (§A.4): the moves that bury the engine ARE the moves that pick cars
|
||
up. Burial is the price of collecting, not a mistake.
|
||
- **Reserving Moves to get home costs 0.55** (t = −2.32), though 62 of 120 trains left on the
|
||
board at game end were stranded in the district. The switching work is worth more than the
|
||
departures.
|
||
- **Granting clearance when the train ahead has one Stage left is −0.98** (t = −5.24). Trains
|
||
move in numeric order, so a follower can enter the region the leader still occupies before
|
||
the leader moves. "About to leave" is not "gone".
|
||
- Preferring coaches at make-up, stocking the platform first, playing Interlocking earlier,
|
||
hunting the Depot in the Departments: all within noise, and three of them were exact
|
||
no-ops — Interlocking sits in hand alongside a train card **0.04 decisions a game**.
|
||
The funnel says why: only **8% of Cargo phases** have a stocked green box, and the bot already
|
||
takes 42% of the turns where stocking is productive. The opportunities are not there to be
|
||
prioritised better. What is left is the economy itself, which is a deck question.
|
||
- [ ] **The bot cannot get a crew next to an industry, so Flying Switch never fires.** Industries are
|
||
now stub-only and the bot places 2.23 a game (was 3.84), in districts averaging under two rows
|
||
deep. `flyingSwitch` is exempted by name in the reachability sweep in `sim.test.ts`; deleting
|
||
that line is the test that this is fixed. Same root cause as the item below.
|
||
- [ ] **THE RUN-AROUND IS OUT OF REACH OF ANY BOT, AND THE DECK IS WHY — measured, five ways.**
|
||
"Teach the bot to plan across turns" was tried properly and does not work. Every attempt is
|
||
neutral or negative, and they fail for one reason that the numbers make plain.
|
||
|
||
| attempt | result |
|
||
|---|---|
|
||
| hold ALL track for the siding | **−0.70** (t = −3.27) |
|
||
| hold only CURVES, the closing piece | **−0.26** (t = −3.22), district 17.9 → 16.7 cards |
|
||
| finish a run before cutting another way down | 0.00 — 398/400 games identical |
|
||
| treat a second turnout as the closing piece | 0.00 — **400/400 identical** |
|
||
| spend a curve only on a square that CLOSES | −0.11, and only 15 games in 400 differ at all |
|
||
|
||
**The pieces never meet.** Over 12,000 Local Operations turns: a turnout and a curve are in
|
||
hand together on **0.3%** of them, and a turnout with a MATCHING-hand curve on **0.2%** — about
|
||
once every eight games. A run-around needs five specific pieces of the right hands in a usable
|
||
order; the bot does not get to the two-piece prerequisite.
|
||
|
||
And it is not hand pressure. The hand is FULL — mean 2.66 cards, at the three-card limit on
|
||
78% of turns. The bot plays 11.4 track cards a game and discards 1.5, so it spends the pieces
|
||
as they arrive because a piece that builds anything outscores holding one that might build
|
||
more later. Holding is the only counter, and holding measures worse every way it is tried.
|
||
|
||
This is a consequence of moving track into the deck, not a bot weakness: 91 run-arounds per 100
|
||
games when track was a private 26-piece supply the player chose from, 29/100 once it was drawn,
|
||
4/60 now. **If the run-around is meant to be the central switching puzzle — and the rules
|
||
present it that way — the supply has to change, not the player.** Options: give track its own
|
||
hand or yard the way the prototype did, raise the hand limit for track specifically, or print a
|
||
siding as a single card. Nothing else reaches it.
|
||
- [ ] **Re-run the three "worth ~0" action-mix experiments against the new floor.** Capping the draw,
|
||
pairing the two halves of a load, and restricting Enhancements were each measured "within noise
|
||
of zero" over 400 games — but at 400 games the standard error is ±0.33, so a real +0.5 would
|
||
have looked like nothing. They are nearly free to re-run now and at least one may have been
|
||
discarded wrongly.
|
||
|
||
---
|
||
|
||
## Play Balance
|
||
|
||
Numbers chosen to fix a measured problem rather than taken from the design. Revisit once the victory
|
||
target is settled and freight carries its intended share; read no balance conclusion from a revenue
|
||
number until the rules stop moving.
|
||
|
||
- [ ] **A DISTRICT CAN NOW ONLY WIDEN AS FAR AS ITS MAIN REACHES (v0.4.8) — worth watching in the
|
||
rebalance rather than acting on now.** Track stays inside the Limits at every row, so extending
|
||
the Running Track is the only way to buy room for sidings, and a straight laid on the sign is
|
||
worth more than it was. The bot barely notices — it built outside its own Limits 5 times in 100
|
||
games — but the bot also builds close to its Office; a human building deliberately hits this on
|
||
the first wide district, which is how it was reported. If territory turns out to be the real
|
||
constraint on freight, this is one of the two places to look (the other is the track supply,
|
||
in Bot Performance).
|
||
- [ ] **REBALANCE, once the rules are right — deliberately deferred.** Card counts, industry counts
|
||
and the track mix all need a pass together, and none of them should move until the rules stop
|
||
moving. Standing distortions to account for when it happens: offices are doubled (Q12) and
|
||
industries tripled (Gap 12), both tuned when the deck held 139 cards and **no track**; it now
|
||
holds 235 of which 96 are track, so every draw is diluted by 41% — precisely the pressure
|
||
those multipliers exist to relieve. The 8 sharp curves have already been taken out on that
|
||
argument; offices and industries are the two left. Until then, read no balance conclusion from the revenue
|
||
numbers; they are a functionality signal only.
|
||
- [ ] **Superseded 2026-08-20 by the victory-condition redesign (Multiplayer) — kept for the
|
||
measurements.** `minCombinedRevenue` replaces the fixed target these numbers were read
|
||
against; re-measure once that lands rather than off this. **RE-MEASURE THE BOT AT THE NEW
|
||
DEFAULTS.** Both provisional rules below are now **settings on
|
||
the New Game dialog** rather than fixed choices, and the defaults are not what the numbers in
|
||
this file were measured under: the opening hand defaults to **three random cards** (the
|
||
prototype rule) rather than 3+3, and **train revenue per transit defaults to 0** rather than 1.
|
||
That second one is the big move — it was worth ~5.4 of a 7.0 mean, so the bot's revenue should
|
||
fall to roughly the working freight-and-passenger economy alone, which is the number this game
|
||
has actually been trying to read all along. Every mean, floor and threshold quoted below and in
|
||
the tests predates it. The three revenue rates run 0–5, so the useful next step is a sweep
|
||
rather than a single re-run.
|
||
- [ ] **Not superseded by the 2026-08-20 victory-condition redesign (Multiplayer) — these two stay
|
||
`houseRules` dials, separate from the new `GameConfig` victory dials.** Noted only so the two
|
||
redesigns aren't conflated. **REVIEW THE TWO NEW RULES ONCE THEY HAVE BEEN
|
||
PLAYED — both went in provisional, and both are
|
||
now selectable rather than fixed.** Jesse's call, both implemented and measured, both flagged
|
||
in `rules-v0.2.md`. What follows is what was measured when each was the only option.
|
||
|
||
**The opening deal (3 track + 3 other, from two separately shuffled piles).** It did what it
|
||
was aimed at, modestly: run-arounds **4/60 → 7/60** and districts **17.9 → 20.3 cards**, with
|
||
revenue unmoved on its own (−0.1, inside noise). Still nowhere near the 91/100 of the
|
||
private-supply era, so the supply question is softened rather than answered. Two things to
|
||
watch at the table: whether opening with six against a limit of three is a real decision or
|
||
just bookkeeping, and whether three is the right number of each.
|
||
|
||
**~~One Revenue for every train that clears your section.~~ Now: one Revenue to EVERY player
|
||
when a train completes its run.** Jesse's revision in v0.4.2. The first version paid the Office
|
||
a train departed, which on a five-Office railroad paid five separate times for one train and
|
||
paid most to whoever it passed first. It pays once now, when the train runs off the end of the
|
||
Division, and it pays the whole table — getting a train the length of the railroad is the
|
||
shared achievement, and every Office it crossed had to clear it.
|
||
Solitaire is nearly unmoved (7.0 → 7.3 mean over 200 games) because one player's departures and
|
||
completions run at almost the same rate; **in a multi-player game the shape is completely
|
||
different** and needs measuring once multiplayer exists — N players × 1 per completed run
|
||
against the old N payments per train. **The victory-target question stays live**: 20 over 5 Days
|
||
is still reachable largely on traffic, which is either the intent or an argument for raising it
|
||
— and at the new default of 0 per transit it is not reachable on traffic at all, which is the
|
||
first thing a playtest should check.
|
||
- [ ] **The marginal Local Operations action is worth ~0, and that is the real ceiling.** Three
|
||
separate attempts to spend the 60 actions better — capping the draw, pairing the two halves of
|
||
a load, restricting Enhancements — each measured within noise of zero over 400 paired seeds.
|
||
76% of the time an outbound industry has neither a stocked green box nor a spotted car, and
|
||
only 5% of Stages have a single workable facility anywhere, yet redirecting actions at that
|
||
does nothing. Something upstream limits how much work exists to do at all; find out what
|
||
before spending more effort on the option mix.
|
||
- [ ] **Freight was stuck at ~2.7 loads a game and three fixes have not moved it.** Sidings,
|
||
facility placement, car selection and the discarded-load leak all raised revenue (3.2 → 6.5)
|
||
without raising `loadStarted` past 2.7. The chain is not leaking and the cars are arriving
|
||
correctly (57% of drops land on a facility that wants them, 0% on one that does not). The
|
||
binding constraint is now upstream of routing: 60 Local Operations actions a game, and a load
|
||
needs a stocked green box AND a spotted car AND a free Laborer to line up in the same Stage.
|
||
Measure how many Stages have all three before changing any heuristic — the answer may be that
|
||
the economy, not the bot, is what caps freight.
|
||
- [ ] **The rolling stock supply is a guess.** `ROLLING_STOCK_SUPPLY` (coach 8+8, boxcar 10+10,
|
||
hopper 8+8, reefer 5+5, tank 6+6, caboose 6) is marked provisional in `content.ts` and was
|
||
scaled alongside the Gap 12 industry increase. Now that the Classification Yard returns stock
|
||
only when the Division Yard empties, these numbers set the real supply pressure. Adjust from
|
||
playtesting rather than theory, and watch whether industry density feels light or heavy at the
|
||
same time.
|
||
- [ ] **Office card density** (Depot 4→8, Station 2→4, Terminal 1→2). Chosen to remove a 25% chance
|
||
of an unwinnable opening deal. Blunt: it lifts the whole ladder and dilutes every other
|
||
category. The better answer may be fewer Terminals, a cheaper first upgrade, or more A/D
|
||
capacity at the Whistle Post itself.
|
||
- [ ] **Industry density** (9 → 27, Gap 12). Restored roughly the prototype ratio. The "freight is
|
||
only 13–18% of gross" figure that motivated this was partly a measurement bug (see the
|
||
`stats.ts` item in Done) and partly the car-selection bug; freight now runs at 37%. Worth
|
||
re-deciding whether 27 is still the right number now that the industries are actually served.
|
||
- [ ] **Train density.** Left alone by decision, but noted: 22 train cards in 140 are drawn less often
|
||
than 22 in 115 were, and trains scheduled fell 2.9 → 2.1 as a side effect of the other density
|
||
changes.
|
||
- [ ] **Superseded 2026-08-20 by the victory-condition redesign (Multiplayer) — kept for the
|
||
measurements and the reasoning.** `LENGTH_PROFILES.target` (20 over 5 Days, `standard`) is
|
||
retiring in favour of `minCombinedRevenue`, defaulting to `3 × players × days` (15 for
|
||
1-player/5-day, not 20) — a different number, deliberately not tuned to match this table.
|
||
Whether the Office-ladder bottleneck below still applies at the new default is worth
|
||
re-measuring once the redesign lands, but the fixed "20" this data argues against no longer
|
||
exists as a target. **The victory target (20 over 5 Days) is out of reach by a factor of
|
||
about four, and the Office ladder is why.** Measured over 800 games with the tuned bot, which
|
||
no longer throws
|
||
revenue away on collisions (0.0 a game, down from 0.4):
|
||
|
||
| trains scheduled | games | revenue | | Office reached | games | trains | revenue |
|
||
|---|---|---|---|---|---|---|---|
|
||
| 0 | 110 | 0.67 | | Whistle Post | 297 | 0.81 | 0.62 |
|
||
| 1 | 379 | 1.69 | | Depot | 272 | 1.49 | 2.92 |
|
||
| 2 | 234 | 3.72 | | Station | 176 | 1.89 | 4.36 |
|
||
| 3 | 68 | 5.68 | | Terminal | 55 | 1.93 | 5.04 |
|
||
| 4 | 9 | 5.78 | | | | | |
|
||
|
||
Revenue is almost exactly linear in trains scheduled — about **1.9 a train** — and trains are
|
||
capped by A/D capacity, which is the Office tier, which is a card you have to draw. So the
|
||
whole economy hangs off one valve: **37% of games never leave the Whistle Post and earn 0.62;
|
||
53% of all games earn nothing at all.**
|
||
|
||
Extrapolating the line, 20 Revenue needs roughly **11 trains and therefore 11 A/D tracks**. A
|
||
Terminal has four. The target is not merely missed, it is structurally unreachable under this
|
||
deck at this Office ladder — no amount of bot skill closes it, and the best game seen in 800
|
||
was 26 against a median of 0.
|
||
|
||
The three ways out are all yours to choose between, and they are different games:
|
||
1. **Lower the target** to what a 5-Day game can produce (6–8 looks like the honest number).
|
||
2. **Open the valve** — more Office cards, or a cheaper first upgrade, or more A/D capacity at
|
||
the Whistle Post, so the ladder is climbed rather than drawn.
|
||
3. **Raise revenue per arrival.** It is 0.46 today; each arrival can in principle pay 2 for
|
||
passengers alone. That is the freight/passenger conversion problem, not the traffic problem.
|
||
|
||
Nothing here is a bot weakness any more, which is what this measurement was waiting on.
|
||
|
||
---
|
||
|
||
## Multiplayer
|
||
|
||
Deferred while planning the server; decisions and reasoning are in `docs/architecture/multiplayer.md`.
|
||
|
||
- [ ] **Let the game join a call and talk to the table.** Long-term. If the game could join a Zoom,
|
||
Teams or Jitsi call and post into its chat, it could carry the whole table's shared state
|
||
without anyone alt-tabbing: the history of actions as they happen, and a prompt when someone
|
||
is holding the game up — "Now waiting on player Alice to complete the Cargo phase."
|
||
- Further out, audio into the same call: a crash when a collision happens, a bell as the Stage
|
||
clock turns over.
|
||
- Further out still, a nudge on a timer — if a player has not moved within some interval, the
|
||
game says so, by beep or by spoken line: "Still waiting on Alice to complete the Cargo
|
||
phase." That turns the turn chart's "waiting on" chip into something a distracted table
|
||
actually notices.
|
||
- [ ] **Multiplayer train make-up is a round, not one player's job.** When a new train is built,
|
||
players take turns adding cars to the consist; in solitaire one player does all of it. The
|
||
engine currently has no per-player turn within the New Train phase, so this is unbuilt rather
|
||
than wrong.
|
||
- [ ] **MULTIPLAYER — three things deliberately deferred while planning the server.** Decisions and
|
||
reasoning are in `docs/architecture/multiplayer.md` §11; these are the ones left open.
|
||
- **Bots should take minimally damaging, defensive actions when a player steps away**, so a
|
||
game is not permanently halted. Deliberately NOT automatic today: a turn timer forfeiting is
|
||
different from a bot competing, and the clearance ruling is the one decision that changes
|
||
another player's score. Bots fill empty seats at lobby time only (D8).
|
||
- **Let a player resign and hand their railroad to a bot** to finish. Same care needed as
|
||
above, but it is consented rather than imposed.
|
||
- **A forcing turn timer — explicitly NOT in the design.** `lobby-and-sessions.md` §5 used to
|
||
specify one: on expiry the server took "the safest legal action", including denying a
|
||
clearance. Cut in the review, because it is the same objection as a bot playing for an absent
|
||
player — the clearance decision changes somebody else's score, so anything that answers it
|
||
automatically changes the game. Explore later if halted games turn out to be a real problem
|
||
at a real table; the reasoning worth keeping is that **deny** is the safe default, since a
|
||
held train costs a Stage and a wrecked one costs 5 Revenue and feeds the collision floor.
|
||
- **~~The opening D12 for the Eastern Division Point (§4.4) decides nothing.~~ Done in
|
||
v0.4.1** — it orders the whole chain now, west to east by ascending roll. The lobby still owes
|
||
it a display: `state.openingRolls` is kept so clients can show the rolls forming the chain
|
||
rather than only the result (`lobby-and-sessions.md` §4).
|
||
- **Revisit the join secret** (D14). One server-wide secret, passed out of band, gates create
|
||
and join. Enough for a private box, probably not enough if `stationmaster.<domain>` is
|
||
pointed at the open internet for long. Note that one-game-at-a-time per person is expected
|
||
usage and deliberately NOT enforced — enforcing it needs cross-game state whose only job is
|
||
deciding when to release someone, and getting that wrong locks a player out.
|
||
- [x] **~~WHY DOES A 4-PLAYER COMPETITIVE GAME END AFTER ~16 STAGES OF A POSSIBLE 60?~~ Answered
|
||
2026-08-20: the collision floor, not the revenue floor.** Traced `checkVictory`
|
||
(`advance.ts:1086-1133`): in competitive mode the revenue floor can only fire at the exact
|
||
Day-5 boundary (Stage 60), so it structurally cannot explain a 16-Stage ending. Only the
|
||
collision floor can (`advance.ts:1076-1080`, 3 collisions in one Day, checked at every Stage
|
||
boundary). `collisionsToday` is one counter every seat feeds, so a 4-player table burns a
|
||
fixed shared budget roughly 4x faster than one player would. Also: `multiplayer.md` §3's
|
||
8-game sample predates `DEFAULT_HOUSE_RULES` (v0.4.2) and most likely ran under what is now
|
||
`LEGACY_HOUSE_RULES` — that sizing data is stale on top of the collision-floor explanation.
|
||
Jesse's call, 2026-08-20: keep the collision caps flat rather than player-scaled (below), so
|
||
16-Stage games under default settings are an accepted, deliberate outcome, not something to
|
||
re-tune away — re-measure `multiplayer.md` §3's sizing table once the redesign lands, but
|
||
expect similar early endings by design.
|
||
|
||
- [x] **~~Victory conditions unified across solitaire, competitive and coop~~ — designed and
|
||
implemented 2026-08-20.** One shared, fully-configurable set of `GameConfig` dials replaces
|
||
`LENGTH_PROFILES.target`, `VictoryCondition: 'firstToTarget'` (confirmed dead — grepped, never
|
||
selected anywhere in the codebase today) and the flat `COLLISION_FLOOR_PER_DAY` constant:
|
||
|
||
| dial | meaning | default |
|
||
| --- | --- | --- |
|
||
| `days` | how many Days the game runs | 5, all modes |
|
||
| `minCombinedRevenue` | everyone loses if the table's total Revenue is below this when Days run out | `3 × players × days` — reuses `collectiveRevenueFloor()` (`content.ts:1020`), now also applied to solitaire (1 player) rather than competitive-only |
|
||
| `maxCollisionsPerDay` | everyone loses immediately, mid-game, once collisions in one Day reach this | 3, **flat — not scaled by players.** Jesse's call: more players means more independent chances to collide, not a bigger shared budget, so multiplayer is deliberately riskier than solitaire at the same default |
|
||
| `maxCollisionsTotal` | same, summed across the whole game | 5, flat, same reasoning |
|
||
| `pvpCardsAllowed` | whether the 22 opponent-directed cards (still unbuilt, see below) are in the deck | forced off in solitaire and coop — no valid target for them in either — on by default in competitive |
|
||
|
||
`0` means "off" for every dial. Win/lose shape is otherwise unchanged from what solitaire
|
||
already does: most Revenue when Days run out wins, unless `minCombinedRevenue` was missed, in
|
||
which case everyone loses — just made configurable per game instead of a fixed `length`
|
||
lookup. Coop keeps its existing "score is the table's total" model, now against a
|
||
configurable floor instead of `profile.target * players.length`.
|
||
|
||
**New Game dialog:** one shared dialog for all three modes, per Jesse — a mode radio button
|
||
at the top, the same field set underneath for all three, greyed out wherever a mode forces a
|
||
value (the PvP checkbox in solitaire/coop). Solitaire gains the four new dials alongside the
|
||
starting-hand and revenue-rate fields it already has; picking a mode only changes the
|
||
defaults, never the field set. **Deal stays disabled for Competitive/Co-op** with a "needs a
|
||
server" note, since Phase 2 didn't yet expose a way to actually start one from the browser
|
||
(see below) — only Solitaire's Deal path is wired to a real game today.
|
||
|
||
- [x] **~~New Train phase car-placement is one player's job even in competitive mode~~ — fixed
|
||
2026-08-20.** §7 (`rules-v0.2.md:346-363`) is explicit: "starting with the Superintendent and
|
||
working left, each player may place ONE car... the round repeats... until the consist is
|
||
full," with a worked 2-player example. `newTrainPhase` (`advance.ts:187-282`) never
|
||
implemented the round: `enterPhase` resets `actorOffset = 0` on entering the phase
|
||
(`advance.ts:172`) and `newTrainPhase` never incremented it the way `playerPhase` does for
|
||
Local Ops (`advance.ts:140`), so the actor was always the Superintendent alone, for every car
|
||
of every train made up that Stage. Fixed by reading the round position off
|
||
`tray.consist.length` instead — it already counts placements toward that tray and resets per
|
||
train with no new state needed. Test in `multiplayer.test.ts`, "the New Train phase
|
||
car-placement round rotates."
|
||
|
||
**Found in the process, not fixed, logged separately:** `newTrain.passCar`'s `check()`
|
||
(`apply.ts:959-967`) tests whether the *entire* Division Yard is empty rather than whether a
|
||
car suitable for *this* tray exists, and `reduce()` has no case for `carPassed` at all
|
||
(`apply.ts:2246-2247`, falls to `default: break` — applying a pass currently mutates nothing).
|
||
Unreachable in practice today: `trainNeedingCars` only ever flags a tray that already has a
|
||
suitable car waiting, so a legal `passCar` for the flagged tray can't occur. Only matters if a
|
||
future change lets the New Train phase address more than one tray at a time. Not fixed here —
|
||
nothing to verify against an intent that can't legally fire.
|
||
|
||
- [ ] **The redaction test (multiplayer.md §7) is more done than the plan suggests, but the
|
||
exhaustive check is still missing.** `test/multiplayer.test.ts`'s "the view shows one seat at
|
||
a time" section (added earlier) already proves `snapshot(s, ..., viewer)` gives each seat its
|
||
own hand, board, Revenue and impediments — traced `snapshot()` itself
|
||
(`src/sim/view.ts:1180-1219`): `hand` reads only `s.decks.hands.get(viewer)`, `deck` is a
|
||
count, other seats' hands appear only as `.length`, and `Frame`'s type has no `seed`,
|
||
`rngState` or card-id-dictionary field for anything to leak through by accident. What exists
|
||
is all spot-checks, though — "this seat's Frame has the right hand length." What's still
|
||
missing is the exhaustive one §7 actually calls for: serialize a seat's `Frame` and assert it
|
||
contains none of another seat's actual card ids and no deck order, so a future careless edit
|
||
is caught rather than assumed safe. Doesn't need a server — buildable now against `snapshot()`
|
||
and the existing `game()`/`playGame` harness already in `multiplayer.test.ts`. Held for now,
|
||
2026-08-20.
|
||
|
||
- [ ] **D19's switching-instrumentation still needs writing, once real people are playing.** "13%
|
||
for the bot" (`multiplayer.md` D19) was a one-off measurement, not code — nothing in `bot.ts`
|
||
or the sim tools logs it today. It needs live human wait-state data, so it can't usefully land
|
||
before Phase 2 and realistically not before Phase 4 (real people at a lobby, not bots). A few
|
||
lines when the time comes: log whether a legal local-only action existed for a waiting player,
|
||
and whether they took it the moment their turn arrived.
|
||
- [ ] **THE 22 OPPONENT-DIRECTED CARDS — 10 Action, 12 Space-use — ARE OUT OF EVERY DECK UNTIL THEY
|
||
ARE BUILT.** Jesse's call. They were already cut from solitaire (Q6, no legal target with one
|
||
player); they are now cut from the competitive deck too, because `checkPlay` answers both
|
||
categories `NOT_IMPLEMENTED` and dealing them would make ~9% of draws reject outright. Flip
|
||
`opponentCardsInDeck` in `setup.ts` when they land. They are played AT another player —
|
||
Watertower, Derail, Railroad Crossing and so on — so they are genuinely multiplayer work, and
|
||
**three Enhancements are waiting on them**: Facing Point Locks, Water Column and Overpass are
|
||
wired and read, and fire only against these cards. Until then those three are dormant by
|
||
design rather than broken.
|
||
- [x] **Multiplayer proper — Phases 0, 1 and 2 done (v0.4.0, 2026-08-20), Phases 3–6 to go.** The
|
||
full plan is `docs/architecture/multiplayer.md` §12. Phase 2 (server core) landed in one pass:
|
||
|
||
- `src/sim/frame-delta.ts` — the live per-seat board delta (`deltaFrame`/`applyDelta`), a
|
||
smaller, purpose-written replacement for reusing `replay.ts`'s `compress()` — that function
|
||
interns strings across a whole recorded array, which a live single-frame push has nothing to
|
||
intern against; only its one-step-back "null if unchanged" idea carried over.
|
||
- **Found and fixed a real bug tracing this**: `actionMenu(game, seat)` only used `seat` for the
|
||
`hand` field — everything else came from `currentActor(game)` regardless of who asked, so a
|
||
server computing every connected seat's Menu would have handed the acting player's legal
|
||
moves to a waiting seat, paired with the wrong seat's cards. Fixed in `game.ts` with a guard;
|
||
tested in `multiplayer.test.ts`.
|
||
- The redaction test (§7) is built — `test/redaction.test.ts` — and passed on the first run
|
||
against the existing `snapshot()`, confirming it was already correct, not just apparently so.
|
||
- `src/server/session.ts` — the game session host (pure logic, no sockets, reuses `game.ts`'s
|
||
`Game`/`submit`/`currentActor`/`actionMenu` wholesale rather than re-deriving intent
|
||
application/narration). **Found while building it**: `submit()` derives the acting player from
|
||
`currentActor(game)` itself and does not check who is calling it — safe for `LocalSession`
|
||
(one possible caller) but not for a server, so the session host verifies `seat ===
|
||
currentActor(game)` itself before ever calling `submit`, rejecting with `NOT_YOUR_TURN`
|
||
otherwise. Idempotent resend (a repeat `seq`) and the illegal-intent path (checked via
|
||
`check()` directly, so a rejection never pollutes the shared narration log with "not allowed"
|
||
text meant only for the submitter) are both handled here too.
|
||
- `src/server/http.ts` / `src/server/index.ts` — plain `node:http`, no framework (confirmed
|
||
nothing to reuse and nothing else warranted — zero runtime dependencies anywhere else in the
|
||
project). `POST /api/game`, `GET /api/stream` (SSE, per-seat, with a 20s heartbeat and an
|
||
`id:` line per push), `POST /api/intent`, and static serving of `dist/` so the server can be
|
||
same-origin with itself (D16). No `gameId`/multi-game concept yet — one game per process,
|
||
matching "Phase 2 has no lobby."
|
||
- `src/web/session.ts` gained `createRemoteSession`; `main.ts`'s `start()` switches on `?seat=`
|
||
presence (D4 — one bundle, unchanged). Every `LocalSession`-only call site in `main.ts`
|
||
(`seed()`, `save()`, `undo()`, the New Game dialog) now goes through an `isLocal()` type guard
|
||
rather than assuming, since `session` can now be either.
|
||
- **Found and fixed a real infrastructure bug**: adding `test/server/` broke `npm test`'s glob.
|
||
`"test": "node --test test/**/*.test.ts"` relied on bash's non-globstar behaviour of passing
|
||
the *literal, unexpanded* pattern through to Node (which then globs it correctly itself) —
|
||
that only happens when the pattern matches *no* files at the shell level. The moment a
|
||
subdirectory existed, bash expanded it to just that one file, and `npm test` silently ran only
|
||
the new suite. Fixed by listing both depths explicitly:
|
||
`"test": "node --test test/*.test.ts test/**/*.test.ts"`.
|
||
- Verified two ways: `test/server/session.test.ts` exercises the session host directly (no
|
||
sockets); a live end-to-end curl smoke test (server started, a 2-player game created, two SSE
|
||
streams opened, an intent rejected from the non-acting seat, accepted from the acting seat and
|
||
broadcast to both, a resent `seq` producing no second push, and the board correctly nulled on
|
||
the second push) — see the session transcript. **Not verified**: an actual browser — no
|
||
browser binary exists in this environment, so `RemoteSession`'s DOM-facing code
|
||
(`EventSource`/`fetch` wiring) compiled and typechecks but was not clicked through visually.
|
||
|
||
**Phase 3 (persistence/resumption) done, same session, 2026-08-21.** Per §12 steps 14-16 and
|
||
`lobby-and-sessions.md` §5-6 (unusually concrete — the exact storage shape was specified, not
|
||
designed here):
|
||
|
||
- `src/server/persistence.ts` — `game.json` (`{engineVersion, seed, config, playerNames,
|
||
history, status, createdAt}`) and `turn-timings.json`, both atomic-rewrite-then-rename, no
|
||
`gameId`/index yet (one game per process, same deferral as Phase 2's `gameId`).
|
||
- `game.ts` gained `fromMultiplayerSave` — `fromSave`'s multi-player sibling, built on
|
||
`newMultiplayerGame`. **Found while testing it**: `fromSave`'s replay loop calls
|
||
`record(game, result.events)` without the `actor` argument `submit()` itself always passes,
|
||
so every replayed line loses its "Player X" attribution — invisible for solitaire (nothing
|
||
ever compares a `fromSave` replay against a live-played log; `undo`'s rebuilt game is itself
|
||
`fromSave`-built, so the one test that compares logs only ever compares two unattributed
|
||
replays against each other) but immediately visible for multiplayer, where anonymous "Chose
|
||
to..." lines are unreadable the moment there is more than one seat. Fixed in the new function;
|
||
**`fromSave` itself still has the gap** — not touched here, since it is used far more widely
|
||
(undo, save/restore, the replay viewer) and deserves its own careful pass rather than a
|
||
touch-in-passing. Worth its own TODO item if picked up.
|
||
- `session.ts` gained `exportSave()`, `resumeSession()`, and turn-timing tracking — a `TurnTiming`
|
||
span (player, phase, day, stage, start/end wall-clock) closes and reopens whenever the acting
|
||
player, phase, Day or Stage changes; recorded entirely in the session host, never touching the
|
||
engine (which must stay clock-free and deterministic) and never stored inside `history` (a
|
||
replay must reproduce a game from decisions alone). No reporting/aggregation/UI on this data
|
||
yet — §5 calls that "optional... if unobtrusive," and the Phase 3 deliverable is the data
|
||
being recorded, not a view of it.
|
||
- `index.ts` loads `game.json` on boot before starting the HTTP listener: version match →
|
||
`resumeSession`, replayed straight through; mismatch → refused explicitly and loudly (the
|
||
file is left untouched, so rolling the running version back recovers it), server starts with
|
||
no active game rather than replaying under the wrong rules.
|
||
- Verified live, matching this phase's own "done when": server started against a fresh data
|
||
directory, a 2-player game created, intents submitted from both seats, **the server process
|
||
killed and restarted**, both `?seat=` streams reconnected and picked up exactly where they
|
||
left off — same Day/Stage/phase, correct whose-turn-it-is, correct narration attribution.
|
||
Separately confirmed the version-mismatch path: hand-edited `engineVersion` to a bogus value,
|
||
restarted, server logged the refusal and started with no active game (confirmed via `POST
|
||
/api/game` succeeding rather than 409ing).
|
||
|
||
---
|
||
|
||
## Other
|
||
|
||
Doesn't fit the above.
|
||
|
||
- [ ] **Real audio, as committed assets.** Everything the game plays is synthesised from oscillators
|
||
(`src/web/sound.ts`), which was the honest choice for a site that fetches nothing — but it is a
|
||
placeholder, not the finished sound. Sound therefore defaults to OFF.
|
||
- **"All aboard" most of all.** It currently goes through the browser's `speechSynthesis`, so
|
||
it is whatever system voice the player happens to have — a robot, not a conductor. A real
|
||
clip is the single biggest improvement available here.
|
||
- `arrive` (a train pulling into an Office), `depart` (a train highballing out of one) and
|
||
`crash` (§10 — a collision) are now synthesised too, v0.4.9 — three chuffing/screeching cues
|
||
built from the same oscillator-and-filtered-noise toolkit as `stage`, wired to `trainArrived`,
|
||
`trainHighballed` (Office departures only), and `trainsDestroyed`. Good enough to keep as the
|
||
real thing rather than a placeholder — no WAV clips needed for these three.
|
||
- **Find and add the rest as assets**: steam whistle, grade-crossing bell, couplers clashing.
|
||
**Every file added needs three things recorded alongside it: the sound file itself, its
|
||
source (where it was obtained from), and its license.** The preferred license is **CC0
|
||
("Creative Commons Zero")** — a public-domain dedication: the creator waives all copyright
|
||
and related rights, so the file may be used, modified, and redistributed for any purpose,
|
||
including commercial, with **no attribution required and no restriction**. That is the
|
||
cleanest fit for a file committed straight into the repo, since it needs no attribution to
|
||
track going forward. Only fall back to an equally-permissive alternative (e.g. a license that
|
||
explicitly permits redistribution with no ongoing obligation) if CC0 isn't available for a
|
||
given sound, and record that license's actual terms plainly rather than assuming they match
|
||
CC0. Files also need to be small enough to commit, and a check that the "fetches nothing
|
||
external" test still passes — assets must be served from the site's own folder, never
|
||
hot-linked.
|
||
- Keep the synthesised versions as the fallback for anything not sourced, so a missing file is
|
||
a quieter game rather than a broken one.
|
||
- [ ] **Regions as the primary model (the other half of §8.2).** The Division map now DRAWS regions,
|
||
deriving position from what the crossing already cost. The engine still models a crossing as a
|
||
countdown of Stages, so two things printed on the cards remain unimplemented:
|
||
- `entryPoints` is declared on every Mainline profile and read nowhere. The Heavy Grade card
|
||
has five named Start positions, and playing Brakeman is supposed to move your entry point
|
||
along the card. The engine gets the same ANSWER by taking a Stage off the crossing, which is
|
||
why the derived drawing looks right — but the mechanism is not the printed one, so a card
|
||
whose starts do not correspond to its speed would be drawn wrong.
|
||
- `implications.md` §6 calls this "the single largest mechanical gap" and asks for typed cards
|
||
with speeds and named entries, with crossing time DERIVED from the region walk.
|
||
Doing it properly changes movement, so it invalidates every balance figure — revenue 8.7, the
|
||
freight numbers, all of it — and needs a full paired re-measure over 400 seeds. Needs the
|
||
source Start-position art for the ten card types before it can begin.
|
||
- [ ] **`card-reference.md`'s industry table may still be stale beyond Grocer's Warehouse, the Oil
|
||
Refinery and Freight House (corrected v0.5.0) — Mine Tipple, Produce Shed and Power Plant were
|
||
NOT re-verified.** The v0.5.0 pass corrected three rows (and the "Freight House is not a card"
|
||
claim across `card-reference.md`, `glossary.md`, `rules-v0.2.md` and `open-questions.md`) on
|
||
Jesse's explicit call. Checking `content.ts` while making that change turned up that
|
||
`mineTipple` and `powerPlant` are ALSO base 1 out/in + 1 Laborer in the engine — the same
|
||
uniform model as the three that were corrected — while `card-reference.md` still prints Mine
|
||
Tipple 3/3/4 and Power Plant 3/3/4, and the "Throughput — why these Laborer counts" section
|
||
right below the table is built entirely on those higher numbers. Flagged inline in
|
||
`card-reference.md` rather than silently rewritten — this needs the same kind of decision Jesse
|
||
made for the other three, not an assumption that the same correction applies, since raising or
|
||
lowering Laborer counts is also a balance question, not only a docs one.
|
||
|
||
---
|
||
|
||
## Done, kept for the reasoning
|
||
|
||
- [x] **Put rolling stock back into circulation.** The Classification Yard was write-only — seven
|
||
writers, no readers — so 37% of all rolling stock left the game by Day 5. Returning it at the
|
||
Day boundary is **+2.32 ± 0.52 (t = 8.79)**, the largest single change measured on this bot,
|
||
and it was ranked THIRD and predicted not to matter because the Division Yard never runs dry.
|
||
The aggregate was the wrong measure; having the right commodity at the right moment is what
|
||
counts.
|
||
- [x] **Make Enhancements reachable at all.** The bot never laid a straight on the Running Track
|
||
(0.00 in 100 games) because two-arc run-arounds do not need one — so 13 of the 18 Enhancement
|
||
cards had nowhere to go, including Interlocking, the only cure for the only penalty in the
|
||
game (`no free A/D track`, 27% of gross). One scored straight fixed it: enhancements placed
|
||
0.64 → 3.01, collision cost 2.70 → 1.91, worst game −47 → −24. Revenue +0.70 ± 0.74 paired
|
||
over 400 seeds — real but not significant alone; the variance reduction is the clearer win.
|
||
- [x] **Stop the bot discarding its own freight.** `canStockProductively` did not check the Division
|
||
Yard while the engine's `stockOutbound` does, so Freight Agent was chosen when nothing could be
|
||
stocked and the follow-through fell through to an unjam that threw a waiting load out of the
|
||
green box — 3.10 a game against 2.71 started. Now 0.00. Revenue 6.0 → 6.5, wins 5 → 8 in 100.
|
||
Also confirmed **routing was never the problem**: 0% of drops land on a facility that does not
|
||
want the car.
|
||
- [x] **Why switching work did not become Revenue.** Answered: it was the freight the crew shuffled,
|
||
not the shuffling. The chain never leaked — 95% of started loads finished — it was barely
|
||
entered, because a load needs a matching empty car spotted and half the industries never asked
|
||
for one. Three fixes later (sidings, facility placement, car selection) revenue is 3.2 → 6.0
|
||
and freight 26% → 37% of gross.
|
||
- [x] **Fix car selection.** Three of six industries were invisible to `wantedCars` — a hand-written
|
||
industry→car map naming two industries that do not exist and omitting three that do — so tank
|
||
cars were dropped **0 times in 100 games**. Derived from `INDUSTRY_PROFILES` now, and the
|
||
second commodity of the two-commodity industries is reachable. Revenue 5.0 → 6.0, freight
|
||
share 25% → 37%, wins 1 → 5 in 100.
|
||
- [x] **Put the industries on the run-around.** Facility placement was unscored — the first legal
|
||
square — so 0.00 facilities a game sat on a loop; now 1.08. The instructive part was the
|
||
second bug: scoring facilities onto the siding row dropped run-arounds 91→36, because the
|
||
anchor test asked a card's KIND rather than its PORTS and an industry in the line read as a
|
||
dead end. Revenue 4.1 → 5.0. Freight did **not** follow, which is the item above.
|
||
- [x] **Make the bot build sidings that are sidings.** 0 run-arounds in 100 games → 91. Three bugs,
|
||
all scoring on local shape without checking it reached anything; the decisive one was that
|
||
`bestTrackLay` never declined a piece, so it spent the track supply on whatever was legal.
|
||
- [x] **Teach the bot what a siding is for.** Nose coupling (§A.3) implemented, so approach direction
|
||
decides which car is droppable; the bot runs around rather than setting out, when the drop can
|
||
follow. Switching activity transformed, revenue unchanged.
|
||
- [x] **Curve geometry.** Curves were topologically identical duplicates of turnouts, and nothing
|
||
reached north, so a district could only be a vertical column. Now two-port rotatable arcs.
|
||
- [x] **Q10 — when track may be laid.** During the "draw a card" option, one piece a turn. Track was a
|
||
card when §6.2 was written; a 26-piece supply has no hand to bound it.
|
||
- [x] **Q11 — which way a Heavy Grade climbs.** Answered from the card: it prints "(Up)" and "Player
|
||
sets orientation", so it is a property of the placed card, not a compass constant.
|
||
- [x] **§6.2's reshuffle.** Implemented, and the `deckReshuffled` event it had already declared and
|
||
narrated — but never emitted or reduced — is now real. Not yet reached in play: solitaire
|
||
Campaign ends with 168.8 of 243 in the deck and four-player Campaign with 86.6, and no run of
|
||
any length has emptied it. It is a safety net rather than a live mechanic today, which is worth
|
||
knowing before tuning draw rates.
|
||
- [x] **Q12 — Whistle Post lock-in.** Players always start at a Whistle Post; office density doubled
|
||
instead.
|
||
- [x] **~~CLEARING AN INBOUND BOX MINTS A CAR — measured at 1.29 a game against a supply of 80.~~
|
||
Re-audited in v0.4.3: rolling stock is EXACTLY CONSERVED, 100 games out of 100, range 0..0.**
|
||
The old audit's premise was right — the two directions were not symmetrical — but the asymmetry
|
||
has since been closed from the other end. `unloadBegan` and `passengersDetrained` now take their
|
||
replacement empty OUT of the Division Yard rather than conjuring it, so a load is a car that
|
||
moved rather than a car that appeared: one leaves the yard, one arrives in Classification.
|
||
Jesse's description of the tabletop procedure confirms this is the intended model — the token
|
||
you push along the MEN|AT|WORK sign IS the car, fetched from the yard by the Freight Agent and
|
||
swapped onto the industry track at the end.
|
||
Both conjuring fallbacks now **throw** rather than minting, so the leak cannot silently return;
|
||
neither fired across the suite or 100 audited games.
|
||
**`ROLLING_STOCK_SUPPLY` is therefore unblocked** — it was waiting on this and can now be tuned
|
||
in the rebalance pass. (The first audit's own arithmetic was off in the same way mine was on the
|
||
first attempt: cars set out on a card, in `card.standing`, are easy to leave out of the count and
|
||
make a conserved game look like a leaking one.)
|
||
- [x] **`state = fold(events)` was not true, and the docs said it was. Settled: the INTENTS are
|
||
canonical.** Measured before deciding — `advance.ts` never calls `reduce`, so **14 of the 46
|
||
event types are never reduced**: the clock, and the entire Mainline phase, which is every train
|
||
movement in the game. Folding the log rebuilds a district and not a railroad. Jesse's call, and
|
||
the cheap one: the plan never needed fold — persistence is `{ engineVersion, seed, config,
|
||
history }` (`multiplayer.md` §10) and the wire carries `Frame`s, not events (D2/D3), so
|
||
reconnection is a fresh Frame rather than an event tail. Making the phase driver reduce would
|
||
have been a rewrite of the most rule-dense code in the project to buy something nothing uses.
|
||
Corrected in the README, four architecture documents and six source comments; `test/events.test.ts`
|
||
pins the unreduced set so that closing the gap later is a deliberate act, and asserts the
|
||
property that does hold. **If you ever do make the phase driver reduce, that test fails and
|
||
tells you which docs now understate the engine.**
|
||
- [x] **Measure with error bars from now on.** Built: `node src/sim/compare.ts 1600 <tweak>=<n>`
|
||
runs the current bot and one variant over the same deals and reports the paired difference.
|
||
Pairing drops σ from ~9 on the level to **5.3 on the difference**, so 1600 seeds gives ±0.13 in
|
||
about 1m45s — the noise floor is now ~±0.15 rather than ±1.0. Threshold to keep a heuristic is
|
||
**t ≥ 3**, and the report prints the better/worse/identical split beside the mean, because a
|
||
mean carried by a skewed tail is a different claim from broad improvement.
|
||
- [x] **Confirm the Classification Yard rule against the source.** Confirmed, and the guess was
|
||
wrong. The rule is: used Rolling Stock to the Classification Yard, used engines and cabooses
|
||
straight back to the Division Yard, and the Classification Yard empties ONLY when the Division
|
||
Yard is bare — then all at once. The Day-boundary version I had invented was far more generous
|
||
and worth **+2.42 revenue a game the game does not actually grant**. Corrected; revenue 9.67
|
||
→ 7.25.
|
||
- [x] **Enhancements are placed but mostly do nothing.** Measured: forbidding every Enhancement
|
||
except Interlocking is worth **-0.01 ± 0.41 (t = -0.04)** over 400 paired seeds. They neither
|
||
pay nor cost. Left alone. Unlocking the Running Track straight put
|
||
nine kinds on the board (telegraph 0.73, waterColumn 0.57 …), but only Interlocking has a
|
||
measured effect. The Telegraph/Telephone/Radio chain adds to the other train's number when
|
||
dispatching facing trains, which may be worth nothing in solitaire; Water Column removes a
|
||
Watertower; Facing Point Locks prevents Derail, which is multiplayer-only. Worth measuring
|
||
what each is actually worth before the bot spends actions on them.
|
||
- [x] **`stats.ts` undercounts freight.** Fixed: both halves counted, freight share 39% → 49%.
|
||
Worth revisiting the **industry density** decision in Play Balance, which was taken on the old
|
||
number.
|
||
- [x] **LEFT AND RIGHT ARE ON THE WRONG DIAGONAL — for turnouts and for curves, the same way.** The
|
||
engine's `left` turnout is `{stem:'w', through:'e', diverge:'s'}`: a train entering at the
|
||
points from the west heads east and the diverging route leaves to its **right**. The engine's
|
||
`left` curve is arc `sw`, which turns an eastbound train **right** as well. One consistent sign
|
||
error in the hand↔diagonal mapping, and it mislabels every track card a player ever holds.
|
||
**The artwork is right and does not change** — `board-svg.ts:462` draws rails from
|
||
`connectionsFor()` geometry alone, so only words are wrong. **Decision: flip the `hand` value on
|
||
the `TRACK_CARDS` rows AND the two variant tables in the same commit**, so the code keeps
|
||
speaking left/right like the physical supply and now means it. Keep the **row order** in
|
||
`TRACK_CARDS` untouched: `setup.ts:95` builds the deck by iterating that array, so flipping only
|
||
the labels leaves pre-shuffle slot 32 holding a `ne_sw` curve before and after, and
|
||
`variantsFor(…)[0]` still `'sw'` — same seed, same board, and every published replay still
|
||
plays. Also: `track.ts:126` arc fallback, `track.ts:191-212` doc block, `view.ts:1041` and
|
||
`view.ts:1208` diagonal phrases, `bot.ts:837-852` `arcInHand`, seven test files, and the prose
|
||
plus ~14 `data-tip="Turnout · left"` strings in `docs/design/track-geometry.html` — which has no
|
||
generator and must be hand-edited. Verify by fingerprinting a fixed seed's board before and
|
||
after: identical geometry, different words.
|
||
- [x] **A turnout should be playable as an UPGRADE, on top of a card already down.** On a straight,
|
||
or on a curve whose arc matches the turnout's diverging leg. Nothing like this exists — the only
|
||
"upgrade" in the game is the Office tier change, which is explicitly not a card swap
|
||
(`apply.ts:1290`), and `canPlaceAt` hard-stops at `if (existing && !isMovableSign) return false`
|
||
(`track.ts:518`). **Decision: cars AND enhancements both block it** — `standing.length > 0` →
|
||
`UPGRADE_OCCUPIED`, `enhancements.length > 0` → `UPGRADE_ENHANCED`, so an Interlocked straight
|
||
stays a straight. Express the curve rule on **arcs, not hands**, so it survives the flip above.
|
||
No extra connection requirement: all four turnout orientations are port supersets of a straight
|
||
and of any same-arc curve, so an upgrade can never sever an existing join, and the new leg is
|
||
allowed to dangle — that is what it is for. The lifted card leaves play, which is already how
|
||
board cards behave (`apply.ts:1249` salvages only cards that were *not* placed). Reuse
|
||
`card.play` with a placement on an occupied square; `legal.ts:252` must offer those squares for
|
||
turnouts, and the `attachments` set at `legal.ts:137` is already exactly that list.
|
||
**The other half of this report needs no work:** a turnout carries the through route
|
||
(`carriesThroughTrack`), so it is already legal at a Limit, along the Running Track and on
|
||
Secondary Track. Confirmed, not re-investigated.
|
||
- [x] **A Depot shows three MEN AT WORK boxes it can never work.** Offices are Passenger Facilities —
|
||
no freight — but `setup.ts:176` gives every one a three-slot `menAtWork` array, and the three
|
||
renderers loop it with no guard while the green and red rows beside them *are* guarded
|
||
(`board-svg.ts:548`, `panels.ts:218`, `replay.ts:522`). **Decision: all three tiers** — Depot,
|
||
Station and Terminal are all Passenger Facilities and all get `laborers: 0`, so the boxes are
|
||
inert on every one. **Fix it in the model, not the renderers**, so it cannot reappear in a
|
||
fourth place: make `menAtWork` nullable and null for passenger facilities, which is what the
|
||
"Freight only" comment at `state.ts:153` has claimed all along. Freight handling must be neither
|
||
allowed nor displayed there. Two more artifacts of the same "an Office is a Facility" modelling
|
||
go with it: `board-svg.ts:562` calls a Depot **"SHIPS + RECEIVES"**, an industry's flow word,
|
||
and `board-svg.ts:599`'s `Math.max(1, trackCap)` draws it a siding slot although its industry
|
||
track has length 0. `FacilityView` needs a `kind` field; it has no freight/passenger flag today.
|
||
- [x] **Two Ice Houses can be built in one district.** Industries already ban duplicates per Office
|
||
Area — `isLockedOut` (`apply.ts:1774`) covers the Mine Tipple half of the report — but **Ice
|
||
House is a Modifier, not an industry**, and `checkPlay`'s modifier branch (`apply.ts:662`) has
|
||
no duplicate check at all. **Decision: extend the ban to modifier kinds, leave enhancements
|
||
alone.** An Interlocking is a plant at one junction, so a second on another straight is a
|
||
different installation, and the Telegraph → Telephone → Radio chain is already gated per card.
|
||
Expect modifiers with `copies > 1` to go partly dead in solitaire, where there is one Office
|
||
Area — correct, since the spare copies exist for other players' districts.
|
||
- [x] **A drawn card lands at the far end of the hand with nothing to mark it.** `apply.ts:1219`
|
||
pushes, the hand renders in state order (`game.ts:355`), and with `flex-wrap` the newest card is
|
||
exactly where the eye is least likely to be. **Decision: reverse in the display layer, not the
|
||
engine** — `view.ts:898-899` (reversing `hand` and `handWhat` identically, or they desync)
|
||
covers the replay viewer and the standalone replay together, and `game.ts:355` covers the play
|
||
page. Keeping `hand.push` means the bot's option-iteration order does not move, so every revenue
|
||
figure in this file stays comparable; an engine `unshift` would invalidate the lot. The marker
|
||
is play-page only — a `Frame` carries card names, not ids, so a replay cannot say which card
|
||
arrived that step. Follow the `game.scheduled` precedent for a `justDrawn` field, but make the
|
||
badge **persist** rather than flash: it says *which card is new*, not *something just happened*.
|
||
A static `::before` badge as `.handcard.target` does it (`panels.ts:262`), no keyframe — the
|
||
innerHTML rebuild would restart an animation on every render. Clear it in `renderUndo()` and
|
||
after `fromSave()`, or a fresh page load badges last session's draw.
|
||
- [x] **The inbound boxes render green in the side panel.** Green is outbound and red is inbound
|
||
everywhere the colour carries direction — `board-svg.ts:528-545`, `replay.ts:452`, rules §9.1 —
|
||
except `panels.ts`, whose shared `boxes()` helper (`panels.ts:162`) emits class `f` for every
|
||
filled box regardless of direction, so the red row at `panels.ts:222` comes out green. The board
|
||
SVG on the same page draws it correctly, which makes the panel actively contradict the board.
|
||
Give `boxes()` its class from the caller as `replay.ts` already does, add `.box.r` in
|
||
`board-svg.ts:820`'s palette, and take the siding off green in both panels and `replay.ts:525`.
|
||
- [x] **An Interlocking on the board is a bare label, and it does nothing.** The enhancement text is
|
||
drawn with no tooltip (`board-svg.ts:651`) while the copy already exists as data in
|
||
`ENHANCEMENT_CARDS` (`content.ts:589`). Done: every card prints its effect, and the one nothing
|
||
reads says so. **CORRECTED — the first pass had five of the ten statuses wrong.** It claimed
|
||
only Telegraph/Telephone/Radio were live, because the survey grepped for four helper function
|
||
names and read "no match" as "no implementation". In fact **seven are live**: those three plus
|
||
Interlocking (`advance.ts:770`), Yard Office (`advance.ts:750`), Small Yard (`apply.ts:405`) and
|
||
ABS Signals (`advance.ts:599`, stored on the Mainline node). Facing Point Locks and Water Column
|
||
are wired but dormant in solitaire; **Overpass alone has no code path at all**. The shipped
|
||
tooltip briefly told players four working cards did nothing, which is worse than the bare label
|
||
it replaced — `enhancements.test.ts` had passing tests for all four the whole time.
|
||
- [x] **A modifier's grant can land on a direction its host cannot use, and nothing says so —
|
||
corrected in v0.4.7.** The earlier "no bug" verdict below was wrong. The reasoning had been that
|
||
a Grocer's Warehouse is inbound-only. It is not — the card reference says "Both" — so the grant
|
||
was being dropped on a direction the facility should have had. Reported again in play as
|
||
"grocer's warehouse didn't get extra outbound slot for truck dock". The suppression machinery
|
||
itself was right and is kept: it still fires for a passenger Modifier beside a Whistle Post,
|
||
which is not a Passenger Facility — and v0.4.7 gives that grant BACK when the Office is
|
||
upgraded, which it never used to. Original note follows.
|
||
Reported as "Ice House added the laborer but not the outbound slot" — **checked, and there is no
|
||
bug**: Ice House prints +1 *outbound*, a Grocer's Warehouse is `flow: 'inbound'` so
|
||
`allows.outbound` is false, and the capacity was raised on a direction that can never render or
|
||
be stocked. The laborer arrived because laborers have no direction gate. **Decision: an
|
||
industry's printed flow is absolute** — drop the grant on hosts that cannot use it rather than
|
||
opening the direction up. So `applyModifier` (`apply.ts:1788`) gates each capacity grant on
|
||
`allows` and grows `industryTrack.length` only by what was actually applied. **Audit all 17
|
||
profiles for the same trap:** `iceHouse`, `truckDock` and `forklifts` all print `addOut: 1` and
|
||
list `grocersWarehouse`; `waitingArea`, `restaurant` and `hotel` print `addOut: 1` for
|
||
`hosts: ['office']`, which includes a Whistle Post. Then say it in both directions — the hand
|
||
tooltip naming which printed hosts cannot use which half (computable from the profiles, no host
|
||
on the board needed), and the panel's "prints N, Modifiers add M" line showing a suppressed
|
||
grant as suppressed instead of quietly omitting it. That delta display already cites the Ice
|
||
House as the bug that motivated it.
|
||
- [x] **Q13 — rear-end collisions on a Mainline card.** Answered: collide on catching up.
|
||
Implemented, and not on cards that print "trains may pass". Invisible to a bot that always
|
||
denies clearance; a bot that always allows drops from 7.34 revenue to **-5.13**.
|
||
- [x] **Nine of the twelve special-train rules are declared and read by nothing.** Done — all
|
||
nine enforced, and one of them deleted instead. `copiesNextScheduled` was never carried by any
|
||
train card: a Second Section is a Maneuver with its own working intent, so the flag was an
|
||
unreachable second description of an existing mechanic. Cost 0.8 revenue and half the wins
|
||
(8.0 → 7.2, 14/200 → 6/200), which is what enforcing restrictions does.
|
||
- [x] **Carry `links` forward in replay frames.** Done, and the premise was wrong in an instructive
|
||
way: measured, `links` was 5% of the `cells` payload. What actually cost was the `what` prose
|
||
(32%), the facility object stored a second time inside its own cell (24%) and the rest of the
|
||
static identity (26%). All three are interned now — 3415 KB → 1877 KB, and a round-trip test
|
||
runs the page's own unpacking function.
|
||
- [x] **Every published replay was dead.** All three replayed **2 intents of roughly 400** and
|
||
presented as short games, exactly as the item below it (now in Replay / Save Games) predicted.
|
||
Re-recorded from bot games with `node src/sim/save-replay.ts`, which verifies each save
|
||
round-trips before writing it, and `harness.test.ts` now fails if a published replay stops
|
||
short. The version-stamp item in Replay / Save Games is still worth doing — this catches the
|
||
breakage, it does not explain it to a player.
|
||
- [x] **Coordinate labels read Y,X on the board — v0.4.9.** Now X,Y everywhere a coordinate is shown
|
||
to a player: the on-card label (`board-svg.ts`), the switching-crew tooltip and button
|
||
(`main.ts`), the rejected-option and switching-group text (`game.ts`, `view.ts`), and the
|
||
blocked-move text (`narrate.ts`). Display-only — `GridCoord{row,col}` already had the right
|
||
geometry (row increases north, col increases east), and internal `Map` keys are untouched.
|
||
- [x] **"No switching" blocked moving a train clear of the mainline — v0.4.9.** The six no-switching
|
||
cards (both expresses, Light Engine, Campaign, Circus, Military) mean may not add or drop cars,
|
||
not may never be touched. `switch.move` now refuses only a move that would couple a fresh car —
|
||
the same way `dropOnly` was already handled — so these trains can still be shunted onto
|
||
Secondary Track to clear the mainline. `switch.dropCars` and `switch.sortConsist` stay blocked.
|
||
- [x] **Q3 corrected: Expedite governs WHERE a train may stand, not WHEN it leaves — v0.4.9.** The
|
||
forced same-Stage departure (`departsThisStage`, the `shiftChange` expedite pass) is gone; an
|
||
expedited train now arrives and is released like any other train, switchable in between. New
|
||
fault instead: left off the Office square when a Mainline Phase begins, it costs 1 Revenue
|
||
(`expediteFault`, `EXPEDITE_FAULT_PENALTY`), every Phase it is still caught there. Resolved
|
||
"3/4 EXPRESS PRINTS A RULE IT CAN NEVER USE" as a side effect — it can now reach the Local
|
||
Operations turn its printed freight rule needs. Revealed a bot gap instead: logged above under
|
||
Bot Performance.
|
||
- [x] **`evaluateClearance` checked only the first occupant it found — v0.4.9.** Found while
|
||
explaining a playtest report: the Superintendent was asked to rule on a same-direction train
|
||
instead of being held against an opposite-direction one also on the card, because the loop
|
||
returned on whichever occupant it examined first rather than checking all of them — invisible
|
||
until the Telegraph/Telephone/Radio exception made it possible for a card to hold two trains at
|
||
once. Now checks every occupant for an absolute bar before offering any judgment call. Pinned
|
||
with a test that fails against the old single-pass code.
|
||
- [x] **The splash page now shows the box art — v0.4.9.** `docs/StationMasterSplashScreen.png`
|
||
(3.0 MB) resized to a 145 KB JPEG (`public/images/`, copied into the build by `build-web.ts`)
|
||
and placed beside the title, tagline, blurb and both buttons in a side-by-side hero, stacking
|
||
to image-above-text on mobile.
|
||
- [x] **A coach may now be set out at the Office — v0.5.0, Jesse's call.** §A.4's blanket "no Rolling
|
||
Stock may be left at the Office" now carries one exception: any train (not just 7/8) may drop
|
||
one or more coaches there; freight and cabooses stay banned. Unlocks the `ENGINE boxcar coach`
|
||
arrangement that used to lock completely — measured at 1,181 refused set-outs over 60 games,
|
||
every one of them this case. `canDropCarsAt` (`track.ts`) takes a `coachesOnly` flag instead of
|
||
refusing the Office outright; trains 7/8's `coachStaysOnStationTrack` rule now forbids the coach
|
||
everywhere EXCEPT the Office, rather than everywhere. The Office's "cars fouling the Running
|
||
Track" collision (`advance.ts`) is exempted for coach-only standing cars, so a legally parked
|
||
coach is not a hazard to the next arrival.
|
||
- [x] **3/4 EXPRESS PRINTS A RULE IT CAN NEVER USE — closed, v0.5.0.** Confirmed already resolved as
|
||
a side effect of the v0.4.9 Expedite fix (see that entry above); removed from Rules Questions.
|
||
- [x] **THE INDUSTRY TABLE VS. THE CARD REFERENCE — v0.5.0, Jesse's call: the engine was right, the
|
||
docs were stale.** `card-reference.md` printed Grocer's Warehouse and Oil Refinery at 2/2 with
|
||
2–3 Laborers, and claimed "'Freight House' is not a card"; the engine already had both
|
||
industries at 1 out/1 in/1 Laborer and already dealt Freight House as a real sixth industry, 6
|
||
copies. Corrected the docs (`card-reference.md`, `glossary.md`, `rules-v0.2.md`,
|
||
`open-questions.md`) to match the engine; no engine change. Turned up that Mine Tipple, Produce
|
||
Shed and Power Plant may be similarly stale — flagged as a new item above rather than assumed.
|
||
- [x] **Poling — closed, v0.5.0.** Confirmed already at 0 copies, the same treatment as Sharp Curves,
|
||
pinned by `mainline-cards.test.ts`. No code change; removed from Rules Questions.
|
||
- [x] **Heavy Grade orientation stays rolled, permanently — v0.5.0, Jesse's call.** The card prints
|
||
"Player sets orientation", but a Heavy Grade sits on the shared Division chain between two
|
||
players (or beyond an end Division Point, next to one) — never inside one player's own district
|
||
— so there is no single player with a fair claim to the choice. Settled as random from the
|
||
seed, identically for solitaire and multiplayer, overriding the card's print. No code change
|
||
(the roll in `setup.ts` was already doing this); the comments and `implications.md` §10 Q11
|
||
previously framed it as a placeholder awaiting an interactive setup phase — corrected.
|
||
- [x] **Engines are not a separate supply from Crew Trays — confirmed, v0.5.0.** `rules-v0.2.md`:339
|
||
(Gap 4b) ties Crew Trays and engine pieces together as one combined resource, `player count +
|
||
3` — not two independently-tracked supplies. The engine already enforces exactly that via
|
||
`crewTrayCount`/`freeTrays`; `NO_FREE_TRAY` already fires exactly when engine supply would run
|
||
out too. No code change; corrected the comments that called this provisional or unsourced.
|
||
- [x] **Player settings, saved — v0.5.0.** A `localStorage` settings object (`SETTINGS_KEY`, separate
|
||
from the game save) now persists district auto-focus mode, sound on/off, and board zoom level
|
||
across reloads — all three previously reset every time. Falls back to today's defaults on a
|
||
missing, corrupt, or disabled `localStorage`, the same guard the save already had.
|
||
- [x] **The test suite's flakiness under `npm test` — fixed, v0.5.0.** Two changes: (1) a `pretest`
|
||
npm script now builds the shared `dist/` once, before `node --test` runs, so every test reading
|
||
`dist/` no longer depends on another test in the file having built it first; (2) the one test
|
||
that actually exercises the build COMMAND now builds into its own `dist-test/` directory
|
||
(`BUILD_DIST_DIR` env var, `scripts/build-web.ts`) instead of rebuilding the shared `dist/` out
|
||
from under the tests reading it. `dist/` is now single-writer.
|
||
- [x] **Curves and turnout diverging legs now draw as smooth curves, not two straight segments
|
||
meeting at a corner — v0.5.0.** `curvedRail` in `board-svg.ts` replaces the old hard-cornered
|
||
"run to the frog, then a straight 45° leg" with a sampled cubic-Bezier easement: tangent to
|
||
horizontal at the east/west edge (so an abutting straight card's rail still reads as one
|
||
unbroken line) and tangent to exactly 45° at the north/south edge (so two stacked curves still
|
||
read as one continuous diagonal). Both plain curve cards and a turnout's diverging leg go
|
||
through this same code path, so both are fixed by the one change.
|
||
- [x] **Wide boards can now be zoomed — v0.5.0.** Discrete zoom presets (75/100/125/150%) for both
|
||
the district grid and the Division map, applied by resizing the rendered SVG's own pixel
|
||
dimensions (not a CSS `transform`), so the existing `overflow-x:auto` scrollbars keep doing the
|
||
panning with no new gesture code. Persisted in the new settings object above.
|