Show the whole Division mainline. district now has option for auto hiding on phases where not needed. scaffold for multi-player geometry showing division. regions on mainline cards implemented. bug fix on laying rail cards. optimized replay (carried links forward). Add sounds, first pass

This commit is contained in:
Jesse
2026-08-04 11:26:18 -04:00
parent ec792dfc15
commit c3c5c59a45
16 changed files with 1249 additions and 129 deletions
+137
View File
@@ -10,6 +10,143 @@ The target is 20 Revenue over 5 Days.
## Unreleased
### A legal move you could not click
Reported from playtesting: laying a straight offered three places in the list and highlighted one on
the board.
Empty squares were drawn by a separate `ghostSvg` and spliced into the SVG afterwards. It computed
its origin over cells PLUS the offered spots, while `officeSvg` sized itself over cells alone — so
the two disagreed the moment a legal square lay outside the played cards, which is every square that
would EXTEND the district. Reproduced on seed 555: three placements offered, the empty one drawn at
y=99 on a canvas 103 tall, i.e. off it entirely.
`officeSvg` now takes the ghosts and sizes itself over both, so there is one origin and one canvas,
and `ghostSvg` is deleted rather than fixed — a second coordinate system was the bug, not a detail
of it. It was also imported by the replay and never called there.
A test walks six seeds and asserts every square the menu offers has a target drawn inside the
canvas: 129 squares across 47 placeable subjects. It fails on the old code with the exact
coordinates above.
### The railroad, heard
A whistle at the end of each Stage, the grade-crossing bell at the end of each Day, and the
conductor when a train is built. **Off by default** — everything here is synthesised rather than
recorded, so it is a placeholder for real audio and a playtester who did not ask for noise should
not get any. One click in the title bar turns it on, and that click doubles as the gesture browsers
require before audio may start.
**Synthesised, not sampled**, and that is a constraint rather than a preference: the site is a static
folder that fetches nothing — there is a test asserting no page reaches an external host — so audio
would have to be committed to the repo, and a plausible whistle is not something to invent. A real
steam whistle is a CHORD of several chambers slightly out of tune with each other plus the breath of
the steam, which three detuned partials and a band of filtered noise get most of the way to. The
bell is inharmonic partials struck twice, which is what separates a bell from a beep.
**"All aboard" is speech, and speech cannot be faked with oscillators.** `speechSynthesis` is built
into the browser, needs no asset and works offline, so it says the words; where the platform has no
voice installed a two-note conductor's call takes its place rather than nothing happening.
The model names WHAT happened — a Stage ended, a Day turned, a train was made up — and the page
decides what that sounds like. A Day boundary rings the bell only: both would collide, and the bell
is the bigger event. The first Stage of the game announces nothing, because a Stage beginning is the
previous one ending and there is no previous one.
Counted against the clock rather than trusted: over a full game, 60 Stage boundaries produced 55
whistles and 5 bells, and 5 Days produced 5 bells.
### Regions, drawn from what the crossing already cost
§2.1 divides a Mainline card into two regions and §8.2 moves a train one region per Stage. The
engine had replaced that with `crossingStages()` — `implications.md` records it plainly: "The
`Region` model is gone" — because ten card types with real speeds cannot be expressed by one region
a Stage. Both `REGIONS_PER_MAINLINE_CARD = 2` and `entryPoints` survived as dead constants.
The map draws regions again without reinstating the mechanic, because the printed cards say how:
they carry **Start positions**, so a train with Brakemen *enters further along the card*. That is
the same fact as "takes a Stage off the crossing", in different coordinates. So position falls out
of what the engine already knows:
entry = REGIONS - stagesTotal position = clamp(entry + elapsed)
| crossing | drawn as | |
| --- | --- | --- |
| 1 Stage (a 60 card) | enters at region 2, gone next Stage | the printed Start position |
| 2 Stages (a 30 card) | region 1 → region 2 | **§8.2 exactly** |
| 3 Stages (slow train) | region 1 → region 1 → region 2 | fixed distance, slow train |
`entry` is deliberately allowed to go negative and only the final position is clamped: that keeps a
slow train's extra Stage at the START, where being slow shows. Clamping the entry instead parked it
at the exit, reading as a train that raced across and then waited — which is what the first version
did, and what the test now forbids.
`Transit` gains `stagesTotal`, set on entry. State, not rules: nothing reads it to decide anything,
so the bot and every balance figure are untouched, and a save is a seed plus intents so there is
nothing to migrate. It cannot be recomputed later — a modifier played onto the card mid-crossing
would change the answer and make the train jump backwards.
Division Points draw one region, the queue. Running Track cards inside a district draw none: a crew
moves there by Moves, not Stages, so it occupies a card outright.
### The Division map shows the whole route
The map drew one box per node, which collapsed each player's entire district into a single "Office"
tile — so the track a train actually runs along was invisible on the only view that shows where
trains are.
An Office now expands into its **Running Track, Limits to Limits**. That is the right cut rather than
a compromise: the through route IS the Running Track, and everything hanging beneath it is secondary
track a crossing train never touches. Sidings, industries and the load pipeline stay in the district
view, which is where they can be read.
West DP · Mainline · [Limits … Office … Limits] · Mainline · [ … ] · Mainline · East DP
**Trains are shown wherever they are.** On a Running Track card, on a Mainline card, queued at a
Division Point. A crew working BELOW the Running Track has no position on the through route, so it
is reported against the district — "2 switching below" — rather than drawn somewhere it is not.
Projecting a siding onto the through line would be a lie the map cannot support.
**Seating.** Players sit around a table, so the route is laid out the way they do: one row alone,
two rows facing, a horseshoe of three, a square of four. The Division is a LINE and not a loop —
trains enter at one Division Point and leave at the other — so the shape is deliberately left open,
with buffer stops at both ends and the gap labelled "trains enter" and "trains leave". Closing it
into a ring would promise a connection the rules do not have.
Layout is geometry with no visual feedback loop, so it is tested rather than eyeballed: for 1, 2, 3
and 4 players no two cells may overlap and none may fall outside the canvas.
### The district folds itself away
Auto-focus. The district is worth its vertical space during the phases that change it — Local
Operations and Cargo — and not during New Train, Mainline or Supervisor Shift, where the Division
map is what matters. Folded, it leaves a summary line rather than vanishing, because a panel that
disappears entirely reads as broken.
A manual toggle overrides it and stays put. This is DISPLAY state and never game state: two players
at the same table may reasonably want it set differently.
### The replay was storing the same things over and over
Halved, near enough: **3415 KB → 1877 KB** on a 735-frame game.
The TODO said to carry `links` forward. Measured first, that would have bought 5% of the `cells`
payload — `cells` is 62% of the file, and inside it the cost is elsewhere:
what 32% of cells long prose, identical on every turnout in the district
facility 24% of cells the SAME object already serialised in the frame's `facilities`
identity 26% of cells row/col/kind/label/running/links, fixed once the card is laid
So all three are interned. A card's identity and its description are written once for the whole
recording and referenced by integer, and a cell points at its facility instead of carrying a second
copy of it. What stays per-frame is what genuinely changes: the cars standing there, the crew, and
any Enhancement laid on the card.
`rehydrateCells` is exported and emitted into the page by `toString()`, the same trick the two board
renderers use — a second copy inside the page's inline script could drift from the packing and the
symptom would be a board drawing the wrong cards rather than an error. The round-trip test runs that
exact function over every frame, resolving nulls the way the page does.
### Rolling stock was leaving the game
Four things were tried against bot revenue. **One of them was worth more than everything else in