v0.7.3 — a game that asks before it ends, and a results screen worth reading
Two issues off the tracker, and they are halves of one thing: the end of a game. Neither ships on the 0.4.9 line — Jesse's call, that line may be complete and these are not fixes people mid-playtest need. EXTENDED PLAY (#11). The official result is settled at the original game length and never changes: in a five-Day game extended to eight, the winner is whoever led at the end of Day 5. Extending grants exactly one Day and the question is put again at the end of it — solitaire the player decides alone, multiplayer it is unanimous and one refusal ends it there. Only days-based endings offer it; a §3.4 collision breach is final, during an extended Day exactly as during the scheduled game. It could not be a client-side change. `check` refused every intent once `status` left `active`; the server never loads a `finished` game back into memory; and a save is `{ seed, config, history }` replayed through the engine, so a "continue" the history does not record did not happen. Hence a fourth status, `awaitingExtension`, and a `game.extend` intent. `config.days` never moves — `extraDays` counts the borrowed Days and `official` freezes the outcome, the standings and the statistics at the first ending. THE RESULTS SCREEN (#16). `GAME OVER — revenueFloor` was `outcome.reason`, an internal enum interpolated into the page at the one moment the game has the player's whole attention. Every reason now has a sentence with the game's own numbers in it. Around it: the result and winner, standings, the rules the game was dealt under, a per-player breakdown, and the railroad — trains through the Division and how many worked en route, loads made up and broken, passengers, cars switched, trains destroyed. It shares the Day-end dialog's blocks rather than reimplementing them, and stays reopenable so continuing does not cost you the results. Statistics are folded, not recorded: `state.tally` counts what the event stream says happened, hooked at `applyIntent` and `advance` because `reduce` never sees the phase driver's events — and those are the interesting ones. Nothing in the rules reads it, and it rides the Frame, so multiplayer gets the same numbers as solitaire from one implementation. THREE BUGS FOUND IN TESTING, all of which would have shipped: - a saved game containing a vote could not be resumed (NO_ACTOR). A history is a flat Intent[] with no seat recorded; the replay derives who acted from the turn order, which cannot work for an intent every seat may send in any order. `game.extend` carries its voter, checked against the authenticated seat. - an all-bot game hung on the question for ever. `driveBots` loops on `currentActor`, null the moment the game stops, so it cannot cast a vote, and the bot-vote driver returned early with no humans to follow. - the balance harness became unbounded — `test/sim.test.ts` went from under a second to never finishing. `randomBot` took another Day about half the time, so every seeded game ran to playGame's 50,000-turn cap. Fixed in the driver, not in a policy, so it holds for bots not yet written. All three have regression tests. 832 tests pass, against 793 before this change. NOT BUILT, and a correction. #16's own comment said `trainStoodStill` "is emitted per Stage, so a run of them is exactly the sat-on-a-siding streak". It is not: reading advance.ts, it fires once per game and only for a train whose profile sets `stopEarnsPoint` — the X18 Circus — with `stopPointClaimed` preventing a second. The streak was built, rendered "1 Stage at (0,0)", and was taken out again. There is no per-Stage "this train did not move" signal in the engine, so "longest an engine sat on a siding" needs one first; TODO.md #36 records what it would take, and the Circus set-up is reported instead. Badges remain the second pass #16 asks for (TODO.md #33), and because the statistics are derived rather than recorded, that pass can add any of them retroactively to games already played and saved. Extended play has not yet been played at a real table (TODO.md #35): the multiplayer vote has only been driven through `session.intent`, never through two browsers. Closes #11 Closes #16 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EAgJSmeV8zrMh55Mj85ESb
This commit is contained in:
co-authored by
Claude Opus 5
parent
510e33bac7
commit
45580d8b61
+110
@@ -19,6 +19,116 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
||||
|
||||
---
|
||||
|
||||
## 0.7.3 — 2026-08-29
|
||||
|
||||
Two issues off the tracker, and they are halves of one thing: the end of a game. Gitea#11 stops the
|
||||
game being over when the timetable runs out, and Gitea#16 replaces the four lines that were shown
|
||||
there with a results screen worth reading. Neither ships on the 0.4.9 line — Jesse's call
|
||||
(2026-08-29): that line may be complete, and these are not fixes people mid-playtest need.
|
||||
|
||||
### The game asks before it ends (Gitea#11)
|
||||
|
||||
"When game ends allow players to continue playing if they wish… don't force end."
|
||||
|
||||
The rule, decided with Jesse: **the official result is settled at the original game length and never
|
||||
changes.** In a five-Day game extended to eight, the winner is whoever led at the end of Day 5.
|
||||
Extending grants exactly **one** Day and the question is put again at the end of it — solitaire the
|
||||
player decides alone, multiplayer it is unanimous and one refusal ends it there and then. Only
|
||||
days-based endings offer it: a §3.4 collision breach is final, during an extended Day exactly as
|
||||
during the scheduled game, because a railroad declared unsafe does not carry on regardless.
|
||||
|
||||
**It could not be a client-side change**, for three reasons that each rule out the others' fixes.
|
||||
`check` refused every intent once `status` left `active`; `server/index.ts` never loads a `finished`
|
||||
game back into memory; and a save is `{ seed, config, history }` replayed through the engine, so a
|
||||
"continue" the history does not record did not happen — the extended game would evaporate on the next
|
||||
reload, Undo or restart. So there is a fourth status, `awaitingExtension`, and the vote is an intent.
|
||||
|
||||
`config.days` never moves. `extraDays` counts the borrowed Days, and `official` — the outcome, the
|
||||
standings and the statistics, frozen at the first ending — is what the results screen reports. That
|
||||
freeze is done by a wrapper around `advance` rather than inside `checkVictory`, so it happens *after*
|
||||
the last Day's events have been counted rather than before them.
|
||||
|
||||
**Three bugs found while testing it, all of which would have shipped:**
|
||||
|
||||
- **A saved game with a vote in it could not be resumed** — `NO_ACTOR`. A history is a flat
|
||||
`Intent[]` with no seat written down; the replay derives who acted from the turn order. That works
|
||||
for every other intent, `mainline.clearance` included, because there is exactly one seat it could
|
||||
have been. Not here: every seat may vote in any order. `game.extend` therefore carries its voter,
|
||||
uniquely, and the server checks it against the seat it authenticated.
|
||||
- **An all-bot game hung on the question for ever.** `driveBots` loops on `currentActor`, which is
|
||||
null the moment the game stops, so it cannot cast a vote; the bot-vote driver returned early when
|
||||
there were no humans to follow, and nothing ever asked. With nobody to follow, the bots' own answer
|
||||
stands — no — and a bot-only game ends on the timetable it was dealt.
|
||||
- **The balance harness became unbounded**, which is how the third one announced itself:
|
||||
`test/sim.test.ts` went from under a second to never finishing. `randomBot` picks uniformly among
|
||||
its legal options, so once the two votes were among them it took another Day about half the time —
|
||||
and because a table may go on granting Days indefinitely, every seeded game ran to `playGame`'s
|
||||
50,000-turn cap instead of a couple of hundred. Giving `developerBot` a policy was not enough: the
|
||||
guarantee has to hold for every policy, so **`playGame` itself declines**. A simulated game plays
|
||||
the timetable it was dealt, whatever the bot would have voted.
|
||||
|
||||
All three have regression tests.
|
||||
|
||||
Bots never lead. They agree only once every human has agreed, which is the rule Jesse set: "bots will
|
||||
not disagree with the human. Humans get to vote first."
|
||||
|
||||
### The results screen (Gitea#16)
|
||||
|
||||
`GAME OVER — revenueFloor` was not a message. It was `outcome.reason`, an internal enum, interpolated
|
||||
straight into the page at the one moment the game has the player's whole attention. Every reason now
|
||||
has a sentence with the game's own numbers in it, and that fix alone answers the issue's "why did the
|
||||
game end?".
|
||||
|
||||
Around it: the result and the winner, the standings, the rules the game was actually dealt under, a
|
||||
per-player breakdown, and **the railroad** — trains through the Division and how many of them did any
|
||||
switching en route, loads made up and broken, passengers worked, cars switched, trains destroyed and
|
||||
what they took with them. It shares the Day-end dialog's standings, target and collision blocks rather
|
||||
than reimplementing them, because two screens reporting the same game must not be able to disagree.
|
||||
It opens itself once per ending and leaves a button to reopen it, which is what stops Gitea#11's
|
||||
extended play costing you the results.
|
||||
|
||||
**"I don't know if we keep statistics on…"** — nothing was being kept, and now `state.tally` is,
|
||||
folded from the event stream. Hooked at `applyIntent` and at `advance`, because `reduce` never sees
|
||||
the phase driver's events and those are exactly the interesting ones: `trainCompleted`,
|
||||
`trainsDestroyed`, `trainStoodStill`. The test that matters asserts **exactly once** — it plays real
|
||||
games, counts the event stream independently, and checks the tally against that count, so a fold
|
||||
hooked twice or nowhere fails whatever the seed.
|
||||
|
||||
Nothing in the rules reads the tally, so adding a counter is always safe. It rides the `Frame`, so
|
||||
multiplayer gets the same numbers as solitaire from one implementation — and `test/redaction.test.ts`
|
||||
gained a case proving a tally never carries a card id, since that is a property of what `tally.ts`
|
||||
chooses to count and not something the types enforce.
|
||||
|
||||
**"Longest an engine sat on a siding" is not in this pass, and the issue comment was wrong about
|
||||
why it could be.** That comment said `trainStoodStill` "is emitted per Stage, so a run of them is
|
||||
exactly the 'sat on a siding' streak you describe". It is not. Reading `advance.ts` rather than
|
||||
trusting it: the event fires only for a train whose profile sets `stopEarnsPoint` — the X18 Circus,
|
||||
the one card in the deck that pays for standing still — and `stopPointClaimed` makes sure it can
|
||||
never fire twice for the same train. The streak was built, shipped nothing but "1 Stage" against a
|
||||
raw grid coordinate, and has been taken out again. The engine has **no per-Stage "this train did not
|
||||
move" signal at all**, so this needs one before it can be answered; `TODO.md` #36 records that. What
|
||||
is reported instead is the Circus set-up itself, which is a real thing that happened.
|
||||
|
||||
**Badges are not here.** The issue asks for them in a second pass after "a whole conversation
|
||||
brainstorming session", and that is where they belong. The tally keeps the raw material — the
|
||||
switching join for "switching master", the standing runs for "longest engine sat on a siding" — and
|
||||
because the statistics are *derived* rather than recorded, a second pass can add any of them
|
||||
retroactively to games already played and saved.
|
||||
|
||||
### Also
|
||||
|
||||
- **A recorded replay plays to its end.** `save-replay.ts` stopped where `currentActor` went null,
|
||||
which since Gitea#11 is the extension question — so a newly recorded file would have replayed to a
|
||||
question nobody answered rather than to a finished game. It declines, like every other bot driver.
|
||||
The three files already published in `public/replays` predate the vote and stop on the question
|
||||
when replayed; `test/harness.test.ts` accepts that as the end of their history, since it is.
|
||||
- The status line stops lying past the last Day. It read `N of TARGET · D Days left` with both halves
|
||||
false; in an extended game it now reads the Day and how far beyond the timetable play has got.
|
||||
- `objectiveOf` paces against the timetable actually being played rather than `config.days`, which
|
||||
otherwise reported "the last Day is over" through every extended Day.
|
||||
|
||||
---
|
||||
|
||||
## 0.7.2 — 2026-08-26
|
||||
|
||||
Five issues off the tracker. Two are engine bugs a player hit at the board, two are the design
|
||||
|
||||
Reference in New Issue
Block a user