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:
Jesse.Markowitz
2026-08-29 04:23:26 -04:00
co-authored by Claude Opus 5
parent 510e33bac7
commit 45580d8b61
30 changed files with 2264 additions and 91 deletions
+44 -1
View File
@@ -398,6 +398,24 @@ export type Frame = {
collisionsTotal: number;
status: GameState['status'];
outcome: GameState['outcome'];
/**
* §3.3, EXTENDED PLAY (Gitea#11). `days` above stays the ORIGINAL timetable — it is what the
* official result was decided at — so the Day the game now runs to is `days + extraDays`.
*/
extraDays: number;
/** Per PLAYER, while `status` is `awaitingExtension`. `null` is a seat that has not voted. */
extensionVotes: (boolean | null)[];
/** The official result, frozen when the original timetable ran out. Null until then. */
official: GameState['official'];
/**
* Gitea#16 — everything interesting that has happened, folded from the event stream.
*
* Aggregate counts only, which is why it can ride the Frame at all: `test/redaction.test.ts`
* proves a Frame carries no other seat's secrets, and a count of trains is nobody's secret. Being
* here rather than on a side channel is what gets the results screen the same numbers in
* multiplayer as in solitaire, from one implementation.
*/
tally: GameState['tally'];
/**
* Every PLAYER's public standing — names and Revenue. "The race is the game" (protocol.md §4).
*
@@ -1026,6 +1044,12 @@ export function describeIntent(s: GameState, i: Intent): string {
}
case 'maneuver.redFlags':
return `set Red Flags to protect ${trainName(s, i.trayId)} — an approaching train must stop short`;
// §3.3, extended play (Gitea#11). The results screen draws its own buttons, but a bot reads its
// options through this list like any other, and the label is what the history says it chose.
case 'game.extend':
return i.agree
? 'play one more Day — the result already recorded still stands'
: 'end the game here';
case 'maneuver.flyingSwitch':
return `Flying Switch ${i.count} car(s) into ${at(i.to)}`;
case 'mainline.clearance': {
@@ -1074,6 +1098,12 @@ export function describeIntent(s: GameState, i: Intent): string {
return 'play your red flag';
case 'maneuver.redFlags':
return `set Red Flags to protect ${trainName(s, i.trayId)} — an approaching train must stop short`;
// §3.3, extended play (Gitea#11). The results screen draws its own buttons, but a bot reads its
// options through this list like any other, and the label is what the history says it chose.
case 'game.extend':
return i.agree
? 'play one more Day — the result already recorded still stands'
: 'end the game here';
default: {
// Every Intent now has a sentence, so `i` narrows to never here. Keeping the assignment makes
// that a COMPILE error the day someone adds an intent without describing it — the playable UI
@@ -1335,6 +1365,10 @@ export function snapshot(
collisionsTotal: s.collisionsTotal,
status: s.status,
outcome: s.outcome,
extraDays: s.extraDays,
extensionVotes: [...s.extensionVotes],
official: s.official,
tally: s.tally,
players: s.players.map((p) => ({
index: p.index,
seat: seatOf(s, p.index),
@@ -1779,7 +1813,16 @@ const SIMPLE_CARDS = [
* view of its own. `0` means no floor is configured — nothing to pace against.
*/
function objectiveOf(s: GameState, viewer: PlayerIndex): Frame['objective'] {
const { days, minCombinedRevenue: target } = s.config;
const { minCombinedRevenue: target } = s.config;
/**
* PACED AGAINST THE TIMETABLE ACTUALLY BEING PLAYED, extensions included (Gitea#11).
*
* `config.days` alone would say "the last Day is over" through every extended Day, and pace an
* eight-Day game against five — both of which the status line used to do the moment play carried
* on past the end. The official result is still decided at `config.days`; that is `checkVictory`'s
* business, and nothing here feeds it.
*/
const days = s.config.days + s.extraDays;
const revenue = s.players[viewer]?.revenue ?? 0;
const daysLeft = Math.max(0, days - s.clock.day + 1);
const elapsed = days - daysLeft + 1;