v0.7.1 — a caboose is not a load, a Day that says it ended, and a train you may throw away

Four issues off the Gitea tracker, all of them things a player saw at the board. Reasoning for
every item, and what was verified how: CHANGELOG.md.

- Gitea#8: X22 Pee-Dee refused every caboose, including the one it was made up with, so setting it
  out stranded the train. All six cabooses are minted loaded because §2.2's "coloured is loaded,
  white is empty" doubles as a piece count in the supply table; one read of the flag took that
  literally. A caboose carries the crew, not freight, so it is never a load.
- Gitea#10: a Day turns over inside the phases that run themselves, so it passes between one click
  and the next — and both transient signals fade before a player reading the board notices. A modal
  stops and waits, carrying the standings, the Days left and the combined target. Suppressed on the
  first frame, on Undo stepping back across a rollover, and on the Day the game ends.
- Gitea#9, which SUPERSEDES Gitea#6 from three days ago: a Timetabled train may be tossed face-up
  to a Department slot, where a rival may pick it up — the second half of the ruling needed no code,
  since that is where every discard already goes. An Extra still may not. A New Game setting on this
  line (discardTimetabled, on by default), the plain rule on the 0.4.9 line.
- Gitea#2 is not an engine bug: the rules are implemented exactly, and running the coach pool dry is
  Jesse's ruling to keep — "part of the strategy". What was wrong is that the game said nothing. A
  blocked platform now gives its reason, from the engine's own predicate, including how many coaches
  are stranded in Classification and what brings them back.

The same four ship as v0.4.9g on the playtest line.

Closes #2
Closes #8
Closes #9
Closes #10

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FLnYR4XtXQNamYJXGYT8oC
This commit is contained in:
Jesse.Markowitz
2026-08-25 10:46:22 -04:00
co-authored by Claude Opus 5
parent 603d38602c
commit 441447648d
17 changed files with 977 additions and 115 deletions
+139
View File
@@ -19,6 +19,145 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
---
## 0.7.1 — 2026-08-25
Four issues off the Gitea tracker, all of them things a player saw at the board. Two are engine or
page bugs, one is a rules ruling that supersedes a ruling from three days earlier, and the fourth
turned out not to be a bug at all — the fix there is that the game now says so. The same four ship
as **0.4.9g** on the 0.4.9 line.
### A caboose is not a load (Gitea#8)
**REPORTED:** X22 Pee-Dee could not couple a caboose — including the one it was made up with. Drop
it at the end of a sweep and it was stranded there, which makes a train whose whole card is a
restriction ("may only pick up MTs") unplayable rather than merely restricted.
`ROLLING_STOCK_SUPPLY` mints all six cabooses as `{ loaded: 6, empty: 0 }`, because §2.2's "a
coloured car is loaded, a white car is empty" is doing double duty there as a PIECE COUNT and there
is no white caboose to make a train up from. So `.loaded` carries two meanings, and the second one
escaped in exactly one place: `pickUpEmptiesOnly`'s `fresh.some((c) => c.loaded)`. Every other read
of the flag in `apply.ts` is already scoped to a coach or to a named car type.
`carriesLoad` now answers the question the rule is actually asking — a caboose carries the crew, not
freight, so it is never a load — and the restriction itself is untouched: a loaded car alongside the
caboose still refuses. `trainRules` says so on the card ("A caboose is not a load"), because a player
reading "EMPTIES ONLY" has no way to know which reading the game took.
### The Day rolls over and says so (Gitea#10)
**REPORTED:** "As the game rolls off the end of the day, you get a dialog saying such. Hard to keep
track of time."
Nothing on screen was wrong. The clock, the turn chart and the timetable all said which Day it was.
What is wrong is WHEN it changes: a Day turns over inside the phases that run themselves, so it
happens between one click and the next, while the player is watching the board and waiting for their
turn. The two transient signals the page already had are both gone before that — the phase banner at
2.6s, the announcement flash at 4.2s. A modal is the whole request: it stops, and it waits.
`dayEndHtml` writes it from the frame AFTER the rollover, so the Day that ended is `f.day - 1`.
It carries the standings in **Revenue order** rather than seat order (the question at the end of a
Day is who is ahead), the Days left to run, and the combined target — reported against the whole
table's Revenue, because `minCombinedRevenue` is a combined floor and one player's score against a
four-player target reads as hopeless when the table is comfortably ahead. Collisions appear only
where §3.4 actually scores them: competitive and co-op, and only when a dial is non-zero. A solitaire
game carries the default dials and enforces neither, so printing a collision budget there would put a
rule on screen that this game does not have.
Three suppressions, each of them a way it would otherwise lie:
- **the first frame** — arriving in a game already on Day 3 is not Day 2 ending, and a page reloaded
mid-game would announce a rollover that happened before it was watching;
- **the Day going DOWN** — that is Undo stepping back across the rollover, not a Day passing. Undo
also clears `lastDay`, so replaying forward through the same rollover does not announce it twice;
- **the Day the game ends on** — the outcome panel is the thing to read then. Verified by playing a
full solitaire game through: the dialog fires four times in a 5-Day game, not five.
### A Timetabled train may be thrown away (Gitea#9)
**REPORTED:** "Timetabled trains are at the choice of the player — they can either play or discard.
If someone else wants to pick it up, they are more than able to. The reason: I don't want, if you
decide to play a game longer than five days, to decide that maybe there are too many trains, the
stations are jammed, and the railroad doesn't need any more. You can toss it. Someone else might
disagree and pick it up."
This **supersedes Gitea#6**, shipped three days earlier in 0.6.2, which made every train card
unconditionally undiscardable. Two things narrow it:
- an **Extra** is still never discardable. It never joins the timetable, so it can never be what
jams it, and the only rule it would dodge by being thrown away is the hand limit;
- the Timetabled half is a **New Game setting** — `discardTimetabled`, on by default — because
Jesse's reasoning is explicitly about games run LONGER than five days, and a five-Day game may well
want Gitea#6's pressure back. Jesse asked for it as a setting on this line and as the plain rule on
the 0.4.9 line, which has no scaffolding for one; both play the same game at main's default.
**The second half of the ruling needed no code at all.** "Someone else might pick it up" — a discard
already goes face-up onto a Department pile, and a Department pile is exactly what a rival draws
from. Only the first half was a change.
**One place decides, and the card says which rule refused.** `keepReason` returns the sentence a
player should read, or `null` if the card may go; `check`, the hand panel and the blocked "End Local
Operations" button all ask it. It returns a SENTENCE rather than a boolean because there are now two
distinct reasons — "an Extra is never discarded" and "not in this game" — and the panel that used to
hard-code one of them would now tell half the players the wrong thing. It reaches the page as the
Frame's `handKeepWhy`, replacing text `panels.ts` and `main.ts` each wrote for themselves.
**Two places would have dropped the setting silently**, both found by looking rather than by `tsc` —
`HouseRuleOverrides`' fields are all optional, so omitting one compiles and falls back to the
default. The New Game dialog's close handler builds its own `houseRules` object (the comment
directly above it warns of exactly this: "a setting missing from here is a setting the dialog
silently discards"), and `presets.ts`'s Frame-to-config path, which is how a JOINER is shown someone
else's game — a setting dropped there shows them a rule the table is not playing.
**Gitea#6's corner survives, narrowed.** A hand of four undiscardable trains still has exactly one
legal way on — play one — with nothing in the engine computing "you must play a train". With the
setting on, the only hand that reaches it is four Extras; with it off, any four trains, as before.
Both are pinned by tests.
### Why nothing is moving on the platform (Gitea#2)
**REPORTED:** "The Sparrow pulled into the station with two loaded coaches. There are two passengers
on the platform. Four porters. My thought was to unload two and load two. I never get the chance to
load the last two."
**The engine is not deviating from the rules**, and this was checked step by step against the
reported save. §9.2 discards the white coach into the Classification Yard on boarding and draws one
from the Division Yard on de-training; §2.2 returns the Classification Yard only when the Division
Yard is empty. All three are implemented exactly. What bites is the interaction: both halves of every
passenger cycle consume coaches one-way, and a single global refill condition over a pile of six
commodities means they do not come back. Traced over the reported game the coach pool goes 8+/8− on
Day 1 to 0+/1− by Day 5, with eight coaches stranded in Classification behind ~60 other cars.
**Jesse's ruling is that the shortage stays** — "it is possible to run out, that's part of the
strategy" — so the three balance options written up in `TODO.md` are declined rather than deferred.
What was unambiguously a bug is that the game said **nothing**. A Porter action that cannot be taken
is simply absent from the menu, and `impediments()` — the panel whose entire job is "why is nothing
moving?" — opened with `if (!f || f.kind !== 'freight') continue`, so a platform had never had
anything to say for itself. The player was not merely blocked; he was given no reason.
A Passenger Facility now reports both directions: passengers standing with no train to take them, a
train whose card bars Porters from working it, a Terminals-only train at a lesser Office, every coach
already full, the red slots full, the same-district rule, and the coach shortage itself — that last
one naming how many coaches are sitting in Classification and the condition that brings them back,
because a yard visibly full of cars that will not yield one coach is the state that looks like a
broken game. The reason text comes from `passengerRefusal`, the engine's own predicate (exported for
this), so what is on screen is the rule that actually refused rather than a second guess at it.
**A second defect fell out of fixing it.** The row's name is read off `card.geometry.facility`, which
a Passenger Facility does not have — it rides on the `office` card — so every passenger impediment
would have read `facility 0,0` beside a freight row saying `mineTipple 1,-3`. It is named by the
Office Area's tier now (`terminal 0,0`), and there is exactly one Office per Area, so that tier is
the card's own.
Verified by replaying the reported save (`playtests/station-master-seed947338225-day5(1).json`)
through `fromSave` and printing the panel. Worth noting for anyone who tries it: that save is a
v0.4.9-line recording and stops at intent 250 of 323 on the 0.7 engine, because Gitea#4, #6 and #7
changed the rules its later intents were recorded against. That is expected divergence, not a
save-format bug — a replay reproduces a game from decisions, and the decisions no longer mean the
same thing.
---
## 0.7.0 — 2026-08-23
The multiplayer set-up, the lobby, the start of a game, and four things a remote client had never