v0.4.4 — New Game dialog for the opening hand and the three revenue rates, Yard renamed Interchange, and two movement bugs behind a mirrored consist

This commit is contained in:
Jesse
2026-08-14 22:39:50 -04:00
parent d1314066bd
commit 6655e20ea8
29 changed files with 5450 additions and 3902 deletions
+199
View File
@@ -21,6 +21,205 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
## Unreleased
## 0.4.4 — 2026-08-14
Out of a play session: engines that pointed north, a card whose name collided with five other things,
a New Game dialog that only asked for a seed — and then, chasing a mirrored consist, two movement
bugs that had been there all along.
Both playtest reports were confirmed against Jesse's own saved game,
`docs/station-master-seed493290760-day2.json` (seed 493290760, two Days, 127 intents), which is kept
as the evidence for what follows.
### A curve is not a straight, and a train that rounds one knows it
**Reported:** "a train reversed into a siding and the display of the cars was reversed."
`facing` is the port the engine would leave by, and a Move recorded it as `opposite(entry)`. That is
the far end of a **straight** and of nothing else: a curve is an arc between two ADJACENT edges, so a
train entering a north-west curve through its west port comes out facing **north**, not east. Every
train that rounded a curve was left facing a port its own card does not have.
Two things went wrong with that, and only the second was visible:
- **Movement.** `movesFor` explores from `facing`, and a port the card lacks yields no destinations
at all — so a crew that rounded a curve could only ever back out the way it came. It could not
continue round the corner it had just taken.
- **Display.** The east-west sense the board draws is carried from `facing`, so a curve that had
really turned the engine west could leave the board still drawing it east — the consist mirrored,
which is what was seen.
The forward case now asks the **card** for its far end (`farPort`). The reverse case is unchanged and
was already right: backing up, the engine trails and points out through the port the train came in
by, whatever the track does underneath — there is a test pinning that so the fix cannot drift into it.
**The reported moment, out of the save.** Three times in two Days the crew backs off the Running
Track into the curved siding at (1,-2) — a `sw` arc, so entering it from the south leaves the engine
facing south. Intent #109, drawn both ways:
```
came from (0,0) office [ew] west cab tnk [>] east
BEFORE the fix west [v] box tnk cab east
AFTER the fix west cab tnk box [>] east
```
The engine was on the east end before the move and is on the east end after it — backing up does not
turn a train around. The old renderer flipped the whole strip to nose-left the moment `facing` stopped
being `'e'`, which is precisely the mirroring that was reported.
### A train leaving a district drops its spur port
The same family, found while fixing the above. Nothing reset `facing` when a train left an Office, so
a crew that had been shunted onto a north-south spur carried a compass port out onto a Division that
runs east and west — and then into the next Office, whose card has no north or south edge at all.
With neither `facing` nor its opposite on the card, `movesFor` returned nothing in either direction:
**the train arrived at the next Office unable to make a single Move.** It also drew a ▲ on the
Division map, where there is no north to point at.
A train out there is running one way along an east-west railroad with its engine at one end, so
`enterMainline` now says so. This is the same stale port that made the ▲ on the Division map — the
`railFacing` change above stopped it being *drawn*, and this stops it existing.
### Departures after Cargo: investigated, not a bug — but one card contradicts itself
Also reported: "a train left at the end of the Cargo phase — shouldn't it wait for the next Mainline
phase?" It should not, and it did not: **only Expedite trains do this**, which is Q3 working as
agreed. In the save it is **6 The Sparrow**, twice — arriving in Stage 9's Mainline and highballing
in Stage 9's Supervisor Shift, then the same in Stage 10 of Day 2. The one non-Expedite train in the
game, 12 Drag Freight, arrived in Stage 8 and left three Stages later in a **Mainline** phase, exactly
as it should. Measured more widely, over 40 bot games, the split is perfect: every departure with no
Local Operations turn was an Expedite train, and every non-Expedite train got at least one.
Two things are worth writing down because they read as bugs and are not:
- **Expedite departs in Supervisor Shift, not in Cargo.** Q3 as recorded says the train "gets a
second `moveTrain` in the same Mainline Phase"; the code deliberately does not, because that had
expedited trains gone before a single Porter could reach them. Supervisor Shift is the last phase
of the Stage, so it is still the Stage the train arrived in — it just looks like "I finished Cargo
and it left", because Supervisor Shift needs no input and runs itself.
- **Expedite costs the train its Local Operations turn**, since Local Ops is phase 1 and the train
arrives in phase 3 and leaves in phase 5. Harmless for four of the five — Crack Limited, The
Sparrow, the Military train and the Light Engine all print "no switching" and would decline it.
**Except for 3/4 Express**, which prints *"may drop or pick up one freight car at every location"*
and is also Expedite. That budget is spent by coupling and setting out, which happen only in Local
Operations — so the Express has a printed ability it can never use: 31 Office visits in 40 games, 31
with no turn to use it in. X14 Fruit Growers Express is in the same position, though its extra-reefer
line is a note today with no mechanics behind it. Left alone pending Jesse's call and recorded in
`TODO.md`; it is a rules contradiction on the card, not a fault in the timing.
### Replays are replaced rather than piled up
Both fixes change which Moves are legal, so the published replays stopped replaying — correctly, and
the liveness test caught it. Re-recording used to write the new set **beside** the old one, and the
old set is precisely the one whose rules have just moved, so dead files accumulated and the test
failed on them forever. `save-replay.ts` now retires what it replaces (only once a replacement has
verified), and writes the `rules` block into each file so a replay can never again be silently
re-dealt. `harness.test.ts` was passing `{ seed, history }` and dropping `rules` on the floor, which
would have replayed every published file under the pre-dialog defaults whatever it said.
### The engine points east or west, always
**Reported:** a crew that turned onto a north-south spur was drawn with a ▲ over it, and the change
of convention was harder to read than no arrow at all.
`facing` on a Crew Tray is a **port** — 'n', 's', 'e' or 'w' — because movement needs one: a crew
standing on a north-south spur has to be able to leave by 'n' or 's', and the last attempt to derive
east/west from the direction of the run stranded 29 of 62 leftover crews on north-south track with no
legal move. So the port stays exactly as it is, and what changed is the **drawing**.
A new `railFacing` on the tray carries the east-west sense across north-south track: it updates
whenever `facing` becomes 'e' or 'w' and holds its value in between. That is the railroad's own
convention, where compass north on a branch is still timetable east. It follows the engine around
180° of curves, because a train that runs forward through two curves really has turned around — and
it does *not* move when a train backs up, because a train that backs up has not.
**It also fixes a bug nobody had reported yet.** Nothing resets `facing` when a train leaves a
district, so a crew that shunted onto a north-south spur and then departed carried its 'n' out onto
the Division map — where there is no north or south — and drew ▲ there too.
The Frame's `facing` is now typed `'e' | 'w'`, so this is enforced rather than merely observed, and
the Office card lays a north-south crew's consist east-west like every other train instead of pinning
it nose-left.
### The Yard mainline card is now the Interchange
Same 60, same "sort cars into any new order", same entry points, same art. What it did not have was a
name of its own: **Division Yard, Classification Yard, Salvage Yard, Yard Office and Small Yard** are
five other things in this game, and none of them is this card. The internal key is renamed with it
(`MainlineKind` `'yard'` → `'interchange'`) so the two cannot drift. The other five are deliberately
untouched — they merely shared a word.
### New game asks for the rules, not just the seed
It was a `prompt()` asking for a seed. Two of the three things that decide what kind of game you are
about to play had no way in at all: the opening hand had been changed twice with no way back to the
earlier rule, and the three revenue rates were constants in the source. Balance is the open question
this game has, and settling it means dealing several games at different settings — which needs a
dialog, not a rebuild.
**Starting hand**, three options, all of which have been the rule at some point:
- **Three random cards** *(new default)* — the prototype rule. At the hand limit already.
- **Six random cards** — twice the choice, still no guaranteed track.
- **Three random track and three random non-track** — the v0.4.x deal, from two shuffled piles.
**Revenue**, three rates, each 0–5:
- **Passenger revenue per coach**, default **1**. Paid when a coach is boarded and again when it is
detrained — both halves of the movement, as it has always worked.
- **Freight revenue per load**, default **1**. Paid when a load is made up outbound and again when it
is broken inbound.
- **Train revenue per transit**, default **0** — *changed from 1*. Paid to every player when a train
runs off the end of the Division. At 1 it was worth ~5.4 Revenue against a bot mean of 7.0: the
railroad was earning most of its money from the one thing nobody has to work for, and the freight
and passenger economies the game is about could not be read through it.
Zero is a real setting rather than "one, suppressed" — no revenue event is emitted at all, so the
history does not fill with "+0 Revenue" for work that did not pay.
**A seed no longer names a game**, so the settings ride in the URL beside it
(`?seed=430&hand=sixRandom&passenger=1&freight=1&transit=0`) and the header carries a readout of what
is in force. A playtest note reading "scored 4" is worthless without it.
**Saves carry the rules they were dealt under.** A save is a seed and a list of intents: replay it
under different rules and it is a different game, and the symptom is not an error but a replay that
quietly stops early — which `TODO.md` records happening twice unnoticed, one of them 42 intents into
360. `Save.rules` is written from now on, and a save without it replays under the pre-dialog rules
(3+3, and 1 per transit) rather than today's defaults, so the three published replays still run.
**Under the hood.** The settings live on `GameConfig.houseRules` as a partial, resolved in one place
by `houseRules()`, which clamps and rounds — so a hand-edited URL cannot deal a game at 900 Revenue a
coach. They reach the page on the `Frame` rather than off the config, because a remote client holds
no `GameState` and still has to be able to answer "what does a load pay here?"; `createLocalSession`
takes house rules rather than a whole `GameConfig` for the same reason.
**Exercised, not just compiled.** Ten developer-bot games per setting, solitaire Standard — far too
small a sample to conclude anything about balance, and enough to show the dials are wired to the
game rather than to the dialog:
| Setting | Dealt | Mean Revenue |
| --- | --- | --- |
| `threeRandom` (default) | 3 | 4.00 |
| `sixRandom` | 6 | 2.00 |
| `threeTrackThreeOther` | 6 | 0.50 |
| pax 1 · frt 1 · **trn 0** (default) | — | 4.00 |
| pax 1 · frt 1 · **trn 1** (the old rule) | — | 9.30 |
| pax 0 · frt 0 · trn 0 | — | −0.20 |
| pax 5 · frt 5 · trn 0 | — | 20.80 |
The transit rule is worth **+5.30** here against the +5.4 measured over 200 games before it became a
setting, which is the number this change was made to get out from underneath. All zeroes lands just
below zero — collision penalties, with nothing left paying — which is the right shape for an economy
switched off. **Read no balance conclusion from the two random-deal rows**: ten seeds is noise, and
the hand rows are three different games rather than the same game dealt differently.
**Tests.** 513 passing, six of them driving the dialog through the *emitted bundle* — the failure
mode here is wiring, not logic. Four existing tests had to be re-pinned rather than merely updated:
the opening deal changes the RNG stream, so `advance.test.ts` was relying on whichever mainline card
seed 1 happened to lay down and now pins its own terrain, and the widest action list was re-measured
over five seeds in all three deals (13 under the random deals, 10 under 3+3).
## 0.4.3 — 2026-08-14
### A load has to have somewhere to go