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
+220 -1
View File
@@ -684,6 +684,139 @@ export type Outcome = {
reason: OutcomeReason;
};
/**
* §3.3, EXTENDED PLAY (Gitea#11) — which endings may be played past.
*
* Both days-based endings offer another Day: running out of timetable, and closing short of the
* combined Revenue floor, are the same event seen twice — the last Day ended and this is what the
* books say. A `collisionFloor` ending is NOT extendable, and neither is a collision breach that
* happens during an extended Day: §3.4 stopped the game because the railroad was declared unsafe,
* and carrying on regardless would contradict the rule that stopped it (Jesse's call, 2026-08-28).
*/
export function isExtendable(reason: OutcomeReason): boolean {
return reason === 'daysElapsed' || reason === 'revenueFloor';
}
/**
* Running counts of everything interesting that has happened, tallied from the event stream
* (Gitea#16).
*
* WHY IT LIVES ON `GameState` rather than being computed by whoever happens to want it. Three
* reasons, in ascending order of how much they cost to work around:
*
* 1. `snapshot()` already takes a `GameState`, so every number here reaches a MULTIPLAYER client
* through the `Frame` it is already being sent — no new server route, no new `Push` field, no
* new `Session` method, and no second implementation that can disagree with the first.
* 2. It is REPLAY-EXACT. A save is `{ seed, config, history }` replayed through the engine
* (`web/game.ts`'s `fromSave`), so a tally folded from the events that replay emits is rebuilt
* identically every time — which is what makes Undo and a server restart correct here for free.
* 3. The official result freezes a COPY of this at the moment the timetable ran out (`official`
* below), and a frozen copy has to be taken from something that already exists.
*
* Aggregate counts only. Nothing here is seat-secret — no card ids, no hands — which is why
* `test/redaction.test.ts` stays green with the whole thing on the Frame.
*
* NOT SCORING. Nothing in here feeds a rule; it is read by the results screen and by the badge work
* that Gitea#16 leaves to a second pass. Adding a counter is always safe.
*/
export type Tally = {
/** §8.3 — a train that ran the length of the Division and left it. */
trainsCompleted: number;
/**
* Of those, how many did some switching between being made up and leaving.
*
* The join Gitea#16 asks for by name ("a player who completes an entire game where every train
* that passed through did some switching on"). Counted as the train completes, against whether
* that tray has coupled or dropped anything since it was made up — which is why `switchedSince`
* below exists rather than this being derivable afterwards.
*/
trainsCompletedWithWork: number;
/** §10 — trains lost to a collision, and the cars that went with them. */
trainsDestroyed: number;
carsDestroyed: number;
/** §6 — switching volume, both directions. */
carsCoupled: number;
carsDropped: number;
/** §9.1 — the MEN | AT | WORK pipeline: begun, and carried all the way through. */
loadsStarted: number;
loadsCompleted: number;
unloadsBegun: number;
unloadsCompleted: number;
/** §9.2 — passenger work. */
passengersBoarded: number;
passengersDetrained: number;
/** Colour, and the vocabulary the badge pass will draw on. */
flyingSwitches: number;
officeUpgrades: number;
dispatchBonusesUsed: number;
facilitiesUnjammed: number;
expediteFaults: number;
trainsHeld: number;
trainsDiverted: number;
secondSections: number;
extrasStarted: number;
cardsDrawn: number;
cardsPlayed: number;
cardsDiscarded: number;
clearancesRequested: number;
/** §8.1 — rulings that let the other train through. A refusal is a ruling too, but not this one. */
clearancesAllowed: number;
/**
* X18 CIRCUS SET-UPS — a train that spent a Stage standing still and was paid for it (§X18).
*
* NOT "the longest an engine sat on a siding", which is what Gitea#16 asks for and what the
* comment on that issue assumed this was. `trainStoodStill` is emitted ONCE IN A GAME PER SUCH
* TRAIN — only for a train whose profile has `stopEarnsPoint`, and `advance.ts` sets
* `stopPointClaimed` so it can never fire twice. There is no per-Stage "this train did not move"
* signal in the engine at all, so a longest-stand streak cannot be folded from the event stream:
* it needs an engine-side signal that does not exist yet. Recorded in `TODO.md` for the badge
* pass rather than shipped as a statistic that would read "1 Stage" for ever.
*/
circusStops: { trainNumber: number; where: string }[];
/**
* Train numbers that have coupled or dropped something since they were made up, for
* `trainsCompletedWithWork`. Cleared when the train is made up and when it leaves the Division.
*/
switchedSince: number[];
/** Indexed by PLAYER. Only events that name a player reach these. */
byPlayer: PlayerTally[];
};
export type PlayerTally = {
loads: number;
unloads: number;
passengersBoarded: number;
passengersDetrained: number;
cardsPlayed: number;
/** §10 — collisions this player was faulted for, not collisions they were caught in. */
collisions: number;
/** Revenue gained and Revenue lost, kept apart: the net is already on `players[i].revenue`. */
revenueGained: number;
revenueLost: number;
};
/**
* THE OFFICIAL RESULT, frozen at the moment the timetable ran out (Gitea#11).
*
* "The winner is based upon the original game length. In a five-day game, even if it's extended to
* eight or nine days, the winner and the official answer is the winner at the end of five days"
* (Jesse, 2026-08-28). So this is written ONCE, at the first ending, and never overwritten —
* including by a §3.4 collision breach during an extended Day, which ends play without touching it.
*
* `state.outcome` keeps moving: it is always the CURRENT evaluation, which is what the live game
* wants. Once `official` exists, everything after it is informational.
*/
export type FinalReport = {
/** The Day the game was scheduled to end on — always `config.days`. */
day: number;
outcome: Outcome;
/** Every player's Revenue at that moment, in player order. */
revenues: number[];
collisionsTotal: number;
/** The Tally as it stood when the timetable ran out. */
tally: Tally;
};
/**
* Per-Stage transient bookkeeping for the acting player. Reset when the actor changes.
*
@@ -726,6 +859,66 @@ export function freshTurns(players: number, moves: number): Map<PlayerIndex, Tur
return turns;
}
/** A Tally with everything at zero — the state every game starts in (Gitea#16). */
export function emptyTally(players: number): Tally {
return {
trainsCompleted: 0,
trainsCompletedWithWork: 0,
trainsDestroyed: 0,
carsDestroyed: 0,
carsCoupled: 0,
carsDropped: 0,
loadsStarted: 0,
loadsCompleted: 0,
unloadsBegun: 0,
unloadsCompleted: 0,
passengersBoarded: 0,
passengersDetrained: 0,
flyingSwitches: 0,
officeUpgrades: 0,
dispatchBonusesUsed: 0,
facilitiesUnjammed: 0,
expediteFaults: 0,
trainsHeld: 0,
trainsDiverted: 0,
secondSections: 0,
extrasStarted: 0,
cardsDrawn: 0,
cardsPlayed: 0,
cardsDiscarded: 0,
clearancesRequested: 0,
clearancesAllowed: 0,
circusStops: [],
switchedSince: [],
byPlayer: Array.from({ length: players }, () => ({
loads: 0,
unloads: 0,
passengersBoarded: 0,
passengersDetrained: 0,
cardsPlayed: 0,
collisions: 0,
revenueGained: 0,
revenueLost: 0,
})),
};
}
/**
* A deep copy, for freezing the official result (`FinalReport`).
*
* Written out rather than reached for via `structuredClone` because a Tally is a flat bag of numbers
* with two containers in it, and spelling the copy out means a field added later that needs deep
* copying is a compile error here rather than a shared reference discovered in a results screen.
*/
export function cloneTally(t: Tally): Tally {
return {
...t,
circusStops: t.circusStops.map((c) => ({ ...c })),
switchedSince: [...t.switchedSince],
byPlayer: t.byPlayer.map((p) => ({ ...p })),
};
}
/**
* WHICH WAY TO DRAW THE ENGINE — east or west, for every train, everywhere.
*
@@ -866,8 +1059,34 @@ export type GameState = {
collisionsToday: number;
/** §3.4 — never reset; checked against `config.maxCollisionsTotal`. */
collisionsTotal: number;
status: 'setup' | 'active' | 'finished';
/**
* `awaitingExtension` is Gitea#11: the timetable has run out, the result is recorded, and the
* table is being asked whether to play one more Day. It is a PAUSE, not an ending — `advance`
* reports `needsInput` there, the server resumes it like any live game, and the only intent the
* rules will accept is `game.extend`.
*/
status: 'setup' | 'active' | 'awaitingExtension' | 'finished';
/** The CURRENT evaluation, re-decided at the end of every Day including extended ones. */
outcome: Outcome | null;
/**
* §3.3 (Gitea#11) — Days granted beyond `config.days`, one vote at a time.
*
* `config.days` is deliberately never touched: it is what the official result was decided at, so
* leaving it alone is what makes "the winner is decided at the original game length" a fact about
* the code rather than a comment on it.
*/
extraDays: number;
/**
* Per PLAYER, while `awaitingExtension`. `null` means they have not voted yet.
*
* Unanimous, and one refusal is decisive: nobody is made to wait on a player who has already said
* no (Jesse's call, 2026-08-28). Solitaire is the same code with one voter.
*/
extensionVotes: (boolean | null)[];
/** Frozen at the FIRST ending and never overwritten. See `FinalReport`. */
official: FinalReport | null;
/** Gitea#16. Folded from the event stream; see `Tally`. */
tally: Tally;
};
// ---------------------------------------------------------------------------