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

This commit is contained in:
Jesse
2026-08-12 21:08:28 -04:00
parent 6ead39c530
commit 1bf1e95058
38 changed files with 8514 additions and 5989 deletions
+186 -35
View File
@@ -13,9 +13,37 @@ Ordered within each section by how much it is currently costing us.
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 243 of which 104 are track, so every draw is diluted by 43% — precisely the pressure
those multipliers exist to relieve. Until then, read no balance conclusion from the revenue
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.
- [ ] **REVIEW THE TWO NEW RULES ONCE THEY HAVE BEEN PLAYED — both went in provisional.** Jesse's
call, both implemented and measured, both flagged in `rules-v0.2.md`.
**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.** Worth exactly **+5.40 a game** —
departures run at 5.40 and each pays 1 — taking the bot from 2.7 to 8.0 mean, median 0 → 6.5,
wins 2/200 → 14/200. It more than doubles the score on its own and pays for traffic the player
does not have to work. **This makes the victory-target question live**: 20 over 5 Days is now
reachable largely on traffic, which is either the intent or an argument for raising the target.
- [ ] **BOT DRIFT ACROSS THIS RELEASE — three measurements, all for the rebalance pass.** Recorded
together so the pattern is visible rather than three relaxed thresholds nobody adds up:
- **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.
- [ ] **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,
@@ -171,46 +199,134 @@ Ordered within each section by how much it is currently costing us.
---
## From playtesting, 2026-08-12
Jesse played and reported nine things. **All of them are now done** — the entries are kept because
each carries the decision behind it, and two of the nine turned out not to be bugs. The measurements
and what went wrong on the way are in `CHANGELOG.md`.
- [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.**
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.
---
## Open questions for Jesse
Blocked on a decision, not on work.
- [ ] **PROPOSED RULE — deal each player six track cards at the start.** Jesse's proposal, and it
aims squarely at the measured problem above: a run-around needs five specific pieces and the
bot holds a turnout and a matching curve together on 0.2% of turns. Six pieces in the opening
hand is roughly the private supply the prototype had, in a form that does not reintroduce an
unbounded one — and it would make the opening district a decision rather than a wait. Questions
before it goes in: are the six drawn from the track cards in the deck (thinning it for
everyone) or from outside it; do they sit in the hand, against the three-card limit, or in a
separate track hand that does not compete; and does the bot's opening change enough to need
re-measuring (it will — this is the one change that could move the run-around numbers).
- [ ] **PROPOSED RULE — one Revenue point for every train that exits your section.** Jesse's
proposal. Worth noting what it would do to the economy as measured: arrivals run at 5.4 a game
and completed runs at about the same, so this is roughly **+5 Revenue a game** against a
current mean of 2.6 — it would more than triple the score and, unlike freight or passengers, it
pays for traffic the player does not have to work. That may be exactly the intent (it rewards
keeping the line clear, which is the Superintendent's job) but it changes what the game is
about, and it interacts with the victory-target question below: 20 over 5 Days becomes
reachable almost on traffic alone. Cheap to measure once decided.
- [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**.
- [ ] **NINE OF THE TWELVE SPECIAL-TRAIN RULES ARE DECLARED AND READ BY NOTHING.** Found by grepping
each flag on `TrainRules` for a reader outside `content.ts`. Only `expedite`, `emptiesOnly` and
`freightTypes` are enforced; `stopEarnsPoint` now is too, after a playtest reported the Circus
Train standing still for a Stage and earning nothing. Still unbuilt:
`noSwitching` · `terminalsOnly` · `coachStaysOnStationTrack` · `oneFreightPerLocation` ·
`noPassengerWork` · `dropOnly` · `pickUpEmptiesOnly` · `stopThenExpedite` · `copiesNextScheduled`
These are what make a special train special — a Circus Train that may not switch, a per-diem
train that may only pick up empties, a Second Section that copies the train ahead. Until they
are enforced, `trainRules()` marks them **NOT YET ENFORCED BY THE ENGINE** in the train's
tooltip rather than listing them as if they applied, because telling a player a rule is in
force when it is not is worse than saying nothing. Each is small on its own; the question is
whether they are worth building before the rebalance, since several of them restrict switching
and would move the freight numbers.
- [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.
- [ ] **THE LOCAL'S COACH HAS NOWHERE TO STAND, AND THAT IS A §A.4 QUESTION.** Trains 7/8 print
"coach must remain on station track if switching", which should mean the coach is set out at
the station while the engine works. It cannot be: §A.4 refuses the Office square to every drop
("Rolling Stock may not be left there"), so "only at the Office" and "nowhere" are the same
rule. It is enforced as "the coach is never set out", which keeps the Local from abandoning it
at an industry but loses the drop-and-collect pattern a real local works. **Decision needed:
should the Office square — or a station track beside it — accept a parked coach?** That is a
change to §A.4, not to the train card, and it would also give the A/D tracks something to do.
- [ ] **Poling.** The only card in the deck with no defined behaviour — the sheet records its effect
as "TBD in the source". A test asserts it stays TBD so nobody invents one.
- [ ] **Heavy Grade orientation at setup.** The card says "Player sets orientation", but `createGame`
@@ -270,6 +386,41 @@ target is settled and freight carries its intended share.
## Not yet built
- [ ] **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.
- [ ] **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.