From 45580d8b61bb74917f7df1339c1d7528af3c5277 Mon Sep 17 00:00:00 2001 From: "Jesse.Markowitz" Date: Sat, 29 Aug 2026 04:23:26 -0400 Subject: [PATCH] =?UTF-8?q?v0.7.3=20=E2=80=94=20a=20game=20that=20asks=20b?= =?UTF-8?q?efore=20it=20ends,=20and=20a=20results=20screen=20worth=20readi?= =?UTF-8?q?ng?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01EAgJSmeV8zrMh55Mj85ESb --- CHANGELOG.md | 110 +++++++++++ README.md | 19 ++ TODO.md | 39 ++++ package.json | 2 +- src/engine/advance.ts | 104 ++++++++++- src/engine/apply.ts | 66 +++++++ src/engine/events.ts | 19 +- src/engine/intents.ts | 29 ++- src/engine/legal.ts | 17 ++ src/engine/setup.ts | 6 +- src/engine/state.ts | 221 +++++++++++++++++++++- src/engine/tally.ts | 201 ++++++++++++++++++++ src/server/session.ts | 73 +++++++- src/sim/bot.ts | 43 +++++ src/sim/narrate.ts | 10 + src/sim/save-replay.ts | 15 ++ src/sim/view.ts | 45 ++++- src/web/game.ts | 43 ++++- src/web/main.ts | 135 ++++++++++++-- src/web/panels.ts | 359 ++++++++++++++++++++++++++++++++---- src/web/play.html | 20 ++ src/web/session.ts | 5 +- test/advance.test.ts | 29 ++- test/extended-play.test.ts | 256 +++++++++++++++++++++++++ test/harness.test.ts | 13 +- test/redaction.test.ts | 24 +++ test/server/session.test.ts | 119 ++++++++++++ test/session.test.ts | 8 +- test/tally.test.ts | 214 +++++++++++++++++++++ test/web.test.ts | 111 ++++++++++- 30 files changed, 2264 insertions(+), 91 deletions(-) create mode 100644 src/engine/tally.ts create mode 100644 test/extended-play.test.ts create mode 100644 test/tally.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 85e899a..e27f624 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,116 @@ page as `v0.1.0 · · `, 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 diff --git a/README.md b/README.md index d330029..388fd51 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,25 @@ is the thing this machinery exists to prevent. roughly a third of the event types are never reduced at all. Anything that needs to rebuild a game replays the intents. - **Never call `Math.random()`.** One ambient random call silently breaks replay. +- **A game ends by PAUSING, and the first ending is the real one.** Running out of Days, or closing + short of the combined Revenue floor, puts the game in `awaitingExtension` rather than `finished`: + the table is asked whether to play one more Day, unanimously, and asked again at the end of every + Day it grants. `state.official` is written at the first ending and never rewritten, so the winner + is always the one decided at `config.days` however long play carries on — `config.days` itself + never moves, and `state.extraDays` counts the borrowed ones. A §3.4 collision breach is the + exception and finishes outright, during an extended Day exactly as during the scheduled game. + Because a save is a replay, the vote is an intent (`game.extend`), and it is the one intent that + **carries its own player**: every seat may vote in any order, so a replay cannot derive who did. +- **Statistics are folded, not recorded.** `state.tally` counts what the event stream says happened — + trains through the Division and how many of them did any switching, loads made up and broken — and + is hooked + at the two boundaries every event crosses exactly once, `applyIntent` and `advance`. It is not + hooked in `reduce`, which never sees the phase driver's events at all. Nothing in the rules reads + it, so adding a counter is always safe; it rides the `Frame`, so a multiplayer client gets the same + numbers as solitaire from one implementation. **What it cannot count is anything the events do not + say.** `trainStoodStill` fires once per game for the X18 Circus alone, so "the longest an engine sat + on a siding" has no signal behind it — see `TODO.md` #36 rather than assuming an event means what + its name suggests. - **A game is one of four TYPES, and a type is a set of defaults rather than a ruleset.** Co-op, Competitive, Cutthroat and Solitaire (`src/web/presets.ts`) each name an opening hand, an Extra rule, three revenue rates and the victory conditions; picking one fills the form, and changing any diff --git a/TODO.md b/TODO.md index 532fb95..0a068eb 100644 --- a/TODO.md +++ b/TODO.md @@ -196,6 +196,45 @@ and pushed** — they were still uncommitted when the session ended. line wherever the tester build is announced, and worth knowing when the next bug report arrives with a save that will not load. +Queued 2026-08-29, from building Gitea#11 and #16 (both shipped in v0.7.3, main only): + +33. **The second pass on the results screen — badges, and the brainstorm Gitea#16 asks for.** The + first pass is in and reports everything the Frame and the event tally know. What it deliberately + does not have is the interesting half: "maybe create badges for anything interesting that + happened… there should be a whole conversation brainstorming session on what are the things that + might be interesting for people to be aware of at the end of the game." The raw material is + already being kept — `tally.trainsCompletedWithWork` is the switching-master join, `longestStand` + is the engine that sat on a siding — and because the statistics are DERIVED from the event stream + rather than recorded, a second pass can add any of them retroactively to games already played and + saved. Needs Jesse and a conversation, not code, to start. + +34. **`replay.ts` prints a raw outcome enum, exactly as the results screen used to.** Its summary + line is `` `${o.result} — ${o.reason}` ``, which renders "loss — revenueFloor" — the same defect + Gitea#16 was filed about, in the dev-side replay viewer rather than the playable page. The + sentences now exist (`panels.ts`'s `reasonSentence`), but they are written against a `Frame` and + the replay recorder has a `GameState`, so it is a small refactor rather than a one-line swap. Not + done in v0.7.3 because nothing about it is player-facing and the change earns its own look. + +35. **Extended play has never been played at a real table.** v0.7.3 is tested — engine, server, + replay, an all-bot regression — and compiled and exercised headlessly, but nobody has sat down, + run a game off the end of its timetable and voted. The multiplayer vote in particular has only + been driven through `session.intent`, never through two browsers: what a second player sees while + waiting on a first, and whether "waiting on Carol" is legible when Carol has closed her laptop, + are both unanswered. Worth being the first thing the next play session does. + +36. **There is no per-Stage "this train did not move" signal, so "longest an engine sat on a siding" + cannot be answered.** Gitea#16 asks for it and the comment on that issue said `trainStoodStill` + would supply it, "emitted per Stage, so a run of them is exactly the streak you describe". That + is wrong, and was found only by reading `advance.ts` while building the tally: the event fires + for a train whose profile sets `stopEarnsPoint` — the X18 Circus and nothing else — and + `tray.stopPointClaimed` guarantees it fires at most once per train per game. A streak folded from + it reads "1 Stage" for ever, which is what v0.7.3 built and then removed. + **What it would take:** either a new event emitted per Stage per stationary tray (cheap to emit, + but it is a lot of events for a statistic nothing scores), or sampling live state on the Stage + boundary the way `stats.ts`'s funnel probe does — which the tally cannot do today, because it + folds a batch of events AFTER `advance` has already mutated past the moment they describe. Worth + settling with the badge pass (#33) rather than on its own, since that is the only consumer. + --- ## Replay / Save Games diff --git a/package.json b/package.json index 37818a4..1a7644e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "station-master", - "version": "0.7.2", + "version": "0.7.3", "private": true, "type": "module", "description": "Station Master — a railroad operations game", diff --git a/src/engine/advance.ts b/src/engine/advance.ts index 034c924..af342dc 100644 --- a/src/engine/advance.ts +++ b/src/engine/advance.ts @@ -38,8 +38,9 @@ import type { GameEvent } from './events.ts'; // the legality test cannot disagree about which train is being assembled. import { areaAtSeat, areaOf, trainNeedingCars } from './apply.ts'; import { legalActions } from './legal.ts'; -import type { CrewTray, DivisionNode, GameState, PlayerIndex, RollingStock, SeatIndex, TrayId } from './state.ts'; -import { coordKey, freshTurns, playerAtSeat, playerLeftOf, pooled, subdivisions, totalRevenue, turnOf } from './state.ts'; +import type { CrewTray, DivisionNode, GameState, Outcome, PlayerIndex, RollingStock, SeatIndex, TrayId } from './state.ts'; +import { cloneTally, coordKey, freshTurns, isExtendable, playerAtSeat, playerLeftOf, pooled, subdivisions, totalRevenue, turnOf } from './state.ts'; +import { tallyEvent } from './tally.ts'; export type AdvanceResult = { events: GameEvent[]; @@ -71,10 +72,58 @@ const step = (d: Direction): number => (d === 'east' ? 1 : -1); // advance // --------------------------------------------------------------------------- +/** + * The phase driver, plus the two things that have to happen around EVERY batch of events it + * produces. `advanceInner` below is the driver itself, unchanged. + * + * ORDER IS THE WHOLE POINT of this wrapper, and it is the one subtle thing in Gitea#16. + * `checkVictory` runs deep inside the driver, so if the official result froze a copy of the Tally + * from in there it would freeze it BEFORE this batch's events had been counted — and the batch that + * ends a game is exactly the one carrying the last Day's work. So the Tally is folded first and the + * result frozen second, both out here where the whole batch is in hand. + * + * Safe because both endings `return` the moment they fire: no scoring event is emitted after a game + * has ended within a single batch, so "everything in this batch" and "everything up to the ending" + * are the same set of events. `test/tally.test.ts` pins that. + */ export function advance(s: GameState): AdvanceResult { + const r = advanceInner(s); + for (const e of r.events) tallyEvent(s, e); + freezeOfficial(s); + return r; +} + +/** + * THE OFFICIAL RESULT, written once (Gitea#11). + * + * "The winner is based upon the original game length" — so the first ending is the real one and + * every later evaluation is informational. Idempotent by construction: it does nothing once + * `official` is set, which is what stops an extended Day, or a §3.4 breach during one, from + * rewriting a recorded win. + */ +function freezeOfficial(s: GameState): void { + if (s.official !== null || s.outcome === null) return; + s.official = { + day: s.config.days, + outcome: { ...s.outcome }, + revenues: s.players.map((p) => p.revenue), + collisionsTotal: s.collisionsTotal, + tally: cloneTally(s.tally), + }; +} + +function advanceInner(s: GameState): AdvanceResult { const events: GameEvent[] = []; if (s.status === 'finished') return { events, needsInput: false }; + /** + * §3.3 (Gitea#11) — the timetable has run out and the table is being asked whether to play one + * more Day. Nothing runs itself while that question is open, so this is `needsInput` rather than + * an ending: `pump` stops here, the server keeps the game in memory, and the only intent the + * rules will take is `game.extend`. + */ + if (s.status === 'awaitingExtension') return { events, needsInput: true }; + // The Superintendent's clearance ruling interrupts the Mainline Phase (§8.1). if (s.clock.pendingDecision !== null) return { events, needsInput: true }; @@ -1313,6 +1362,14 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult { s.config.maxCollisionsTotal > 0 && s.collisionsTotal >= s.config.maxCollisionsTotal; if (perDayBreach || totalBreach) { s.status = 'finished'; + /** + * NOT EXTENDABLE, AND IT DOES NOT REWRITE A RECORDED RESULT (Gitea#11). + * + * A breach during an EXTENDED Day ends play at once, exactly as it would during the regular + * game — but by then the official result already exists, and a railroad declared unsafe on + * Day 9 does not retract who won on Day 5. `freezeOfficial` is what keeps that true: it + * writes only when `official` is still null, so assigning `outcome` here is safe. + */ s.outcome = { result: 'loss', winner: null, reason: 'collisionFloor' }; return { events, needsInput: false }; } @@ -1356,28 +1413,55 @@ function rotateSeats(s: GameState, events: GameEvent[]): void { function checkVictory(s: GameState, _events: GameEvent[]): boolean { const daysElapsed = s.clock.day - 1; - if (daysElapsed < s.config.days) return false; + /** + * `extraDays` is Gitea#11. `config.days` is never touched by an extension — it is what the + * OFFICIAL result is decided at — so the Day the timetable currently runs to is the sum of the + * two. On the first ending they are equal, which is why `freezeOfficial` can record `config.days` + * as the official Day without asking anything further. + */ + if (daysElapsed < s.config.days + s.extraDays) return false; - s.status = 'finished'; + s.outcome = decideOutcome(s); + /** + * §3.3, EXTENDED PLAY — an ending the table may play past PAUSES rather than finishing. + * + * `freezeOfficial` (the `advance` wrapper) records the first of these as the official result, so + * by the time a second one is reached the winner is already settled and everything here is + * informational. The votes are cleared each time because the question is asked again at the end + * of every extended Day: agreeing once does not agree to the rest of the game. + */ + if (isExtendable(s.outcome.reason)) { + s.status = 'awaitingExtension'; + s.extensionVotes = s.players.map(() => null); + } else { + s.status = 'finished'; + } + return true; +} +/** + * WHO WON, on the evidence as it stands right now. + * + * Split out of `checkVictory` for Gitea#11: it is asked once per ending, and an extended game has + * more than one. Unchanged in substance — the revenue floor, then co-op's shared achievement, then + * the highest Revenue — it simply no longer writes to the state it is reasoning about. + */ +function decideOutcome(s: GameState): Outcome { const combined = totalRevenue(s); if (s.config.minCombinedRevenue > 0 && combined < s.config.minCombinedRevenue) { - s.outcome = { result: 'loss', winner: null, reason: 'revenueFloor' }; - return true; + return { result: 'loss', winner: null, reason: 'revenueFloor' }; } if (s.config.mode === 'coop') { - s.outcome = { result: 'win', winner: null, reason: 'daysElapsed' }; - return true; + return { result: 'win', winner: null, reason: 'daysElapsed' }; } const best = Math.max(...s.players.map((p) => p.revenue)); - s.outcome = { + return { result: 'win', winner: s.players.findIndex((p) => p.revenue === best), reason: 'daysElapsed', }; - return true; } // --------------------------------------------------------------------------- diff --git a/src/engine/apply.ts b/src/engine/apply.ts index 039b6fa..02175c3 100644 --- a/src/engine/apply.ts +++ b/src/engine/apply.ts @@ -52,6 +52,7 @@ import type { TrayId, TurnoutOrientation, } from './state.ts'; +import { tallyEvent } from './tally.ts'; import { createRng } from './rng.ts'; import { carsOn, @@ -769,6 +770,23 @@ export function passengerRefusal( // --------------------------------------------------------------------------- export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCode | null { + /** + * §3.3, EXTENDED PLAY (Gitea#11) — asked ABOVE the status guard, because the whole point of the + * vote is that it is the one thing the rules will take from a game that has stopped. + * + * Out of turn like `mainline.clearance` below, and unlike it open to every seat at once: it is a + * table decision rather than a ruling, so there is no actor to be. + */ + if (i.type === 'game.extend') { + if (s.status !== 'awaitingExtension') return 'NOT_AWAITING_EXTENSION'; + // The intent NAMES its voter so that a save can be replayed (`intents.ts`), which makes it a + // claim until it is checked against the seat the caller authenticated. One seat may not vote + // for another. + if (i.player !== player) return 'NOT_YOUR_TURN'; + if (s.extensionVotes[player] !== null) return 'ALREADY_VOTED'; + return null; + } + if (s.status !== 'active') return 'WRONG_PHASE'; // The clearance ruling is the one intent that arrives out of turn order: it interrupts the @@ -1532,6 +1550,27 @@ export function movesFor( function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] { switch (i.type) { + /** + * §3.3, EXTENDED PLAY (Gitea#11) — the vote, and what it settles. + * + * Decided HERE rather than in `reduce` because the answer depends on the votes as they stand + * BEFORE this one lands, and `execute` is the half that still sees that. Three outcomes: + * + * - a refusal ends it immediately. Unanimity means one "no" is decisive, so nobody is made to + * wait on a player who has already said no (Jesse's call, 2026-08-28); + * - the last outstanding "yes" grants the Day — ONE Day, and the question is put again at the + * end of it; + * - anything else is just a vote recorded, and the table waits. + */ + case 'game.extend': { + const vote: GameEvent = { type: 'extensionVoted', player, agree: i.agree }; + if (!i.agree) return [vote, { type: 'playConcluded', declinedBy: player }]; + const after = s.extensionVotes.map((v, p) => (p === player ? true : v)); + return after.every((v) => v === true) + ? [vote, { type: 'dayExtended', day: s.config.days + s.extraDays + 1 }] + : [vote]; + } + case 'localOps.choose': return [{ type: 'localOpsOptionChosen', player, option: i.option }]; @@ -1942,6 +1981,30 @@ function findTimetableSlot(s: GameState, from: number): number | null { export function reduce(s: GameState, e: GameEvent): void { switch (e.type) { + // -- §3.3, extended play (Gitea#11) + case 'extensionVoted': + s.extensionVotes[e.player] = e.agree; + break; + + /** + * One more Day, and the votes are wiped: agreeing once does not agree to the rest of the game. + * + * `config.days` is deliberately untouched. It is what the OFFICIAL result was decided at + * (`state.ts`'s `FinalReport`), 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. `outcome` is left + * alone too — it is the last evaluation, and it is what the results screen shows while the extra + * Day is played. + */ + case 'dayExtended': + s.extraDays += 1; + s.extensionVotes = s.players.map(() => null); + s.status = 'active'; + break; + + case 'playConcluded': + s.status = 'finished'; + break; + case 'localOpsOptionChosen': turnOf(s, e.player).option = e.option; break; @@ -3052,6 +3115,9 @@ export function applyIntent(s: GameState, player: PlayerIndex, i: Intent): Apply const events = execute(s, player, i); for (const e of events) reduce(s, e); + // Gitea#16 — the intent half of the fold; `advance` does the phase driver's half. See `tally.ts` + // for why it cannot simply live inside `reduce`. + for (const e of events) tallyEvent(s, e); return { ok: true, events }; } diff --git a/src/engine/events.ts b/src/engine/events.ts index 6b39675..6b3bc35 100644 --- a/src/engine/events.ts +++ b/src/engine/events.ts @@ -195,6 +195,23 @@ export type GameEvent = | { type: 'unloadBegan'; player: PlayerIndex; at: GridCoord; carType: CarType; carIndex: number } // -- consequences | { type: 'revenueChanged'; player: PlayerIndex; delta: number; total: number; reason: string } - | { type: 'phaseEnded'; player: PlayerIndex; phase: string }; + | { type: 'phaseEnded'; player: PlayerIndex; phase: string } + // -- §3.3, extended play (Gitea#11) + /** + * One seat's answer to "play one more Day?". Every seat votes; the vote is unanimous, and one + * refusal ends it. In the log so that a table can see who is still being waited on, and who + * called time. + */ + | { type: 'extensionVoted'; player: PlayerIndex; agree: boolean } + /** The table agreed. `day` is the Day the extra one becomes — `config.days + extraDays`. */ + | { type: 'dayExtended'; day: number } + /** + * Play is over for good — somebody declined the extension. + * + * Distinct from the ending itself, which `checkVictory` already announced by way of the result: an + * ending that COULD have been played past and was not is a decision the table made, and the log + * should say so rather than simply stopping. + */ + | { type: 'playConcluded'; declinedBy: PlayerIndex }; export type EventType = GameEvent['type']; diff --git a/src/engine/intents.ts b/src/engine/intents.ts index 814c763..80a824e 100644 --- a/src/engine/intents.ts +++ b/src/engine/intents.ts @@ -147,7 +147,28 @@ export type Intent = | { type: 'laborer.startLoad'; at: GridCoord } | { type: 'laborer.advanceLoad'; at: GridCoord; box: number } | { type: 'laborer.beginUnload'; at: GridCoord; carIndex: number } - | { type: 'loadUnload.end' }; + | { type: 'loadUnload.end' } + /** + * §3.3, EXTENDED PLAY (Gitea#11) — one vote on whether to play one more Day. + * + * ARRIVES OUT OF TURN, like `mainline.clearance`, and unlike it goes to EVERY seat rather than to + * the Superintendent: it is a table decision, not a ruling. Unanimous, and one `agree: false` + * ends the game immediately — nobody waits on a player who has already refused. + * + * It is an intent, rather than a button the client handles by itself, because a save is + * `{ seed, config, history }` replayed through the engine: a decision that is not in the history + * did not happen, and an extended game would evaporate on the next reload, Undo, or server + * restart. This is the record of the table agreeing. + * + * CARRIES ITS VOTER, uniquely among intents, and it has to. A saved history is a flat `Intent[]` + * with no seat recorded against each move: the replay DERIVES who acted from the turn order + * (`fromMultiplayerSave`). That works for every other intent, including `mainline.clearance`, + * because there is exactly one seat it could have been. Here there is not — every seat may vote, + * in any order — so a vote whose voter is not written down cannot be replayed at all, and a + * resumed server would refuse the save with `NO_ACTOR`. The server checks this against the seat + * it authenticated (`NOT_YOUR_TURN`), so it is a record, never a claim. + */ + | { type: 'game.extend'; player: PlayerIndex; agree: boolean }; export type IntentType = Intent['type']; @@ -276,7 +297,11 @@ export type RejectionCode = * §6.2, Jesse's ruling (Gitea#6) — a train card is never discarded. Hold it as long as you like; * the only way it leaves your hand is onto the timetable. */ - | 'TRAINS_ARE_NEVER_DISCARDED'; + | 'TRAINS_ARE_NEVER_DISCARDED' + /** §3.3 (Gitea#11) — `game.extend` when the game is not waiting on an extension vote. */ + | 'NOT_AWAITING_EXTENSION' + /** §3.3 (Gitea#11) — this seat has already voted on this extension. */ + | 'ALREADY_VOTED'; export type Rejection = { code: RejectionCode; message: string }; diff --git a/src/engine/legal.ts b/src/engine/legal.ts index e24cede..96eb31e 100644 --- a/src/engine/legal.ts +++ b/src/engine/legal.ts @@ -68,6 +68,23 @@ export function isLegal(s: GameState, player: PlayerIndex, i: Intent): boolean { function candidates(s: GameState, player: PlayerIndex): Intent[] { const out: Intent[] = []; + /** + * §3.3, EXTENDED PLAY (Gitea#11) — the only thing on offer when the timetable has run out and the + * table is being asked whether to play on. + * + * Returned EARLY rather than added to the list, because nothing else is legal in this state and + * the phase switch below would otherwise generate a boardful of candidates for `check` to reject + * one at a time. It also puts the vote in front of the bot driver through the ordinary path, which + * is what lets a bot seat answer without the engine having to know which seats are bots. + */ + if (s.status === 'awaitingExtension') { + if (s.extensionVotes[player] === null) { + out.push({ type: 'game.extend', player, agree: true }); + out.push({ type: 'game.extend', player, agree: false }); + } + return out; + } + // The clearance ruling arrives out of turn order and goes to the Superintendent (§8.1). if (s.clock.pendingDecision !== null) { out.push({ type: 'mainline.clearance', allow: true }); diff --git a/src/engine/setup.ts b/src/engine/setup.ts index ba80d5e..7817623 100644 --- a/src/engine/setup.ts +++ b/src/engine/setup.ts @@ -43,7 +43,7 @@ import type { TrackCard, TrayId, } from './state.ts'; -import { coordKey, freshTurns } from './state.ts'; +import { coordKey, emptyTally, freshTurns } from './state.ts'; export type SetupOptions = { id: string; @@ -434,6 +434,10 @@ export function createGame(opts: SetupOptions): GameState { collisionsTotal: 0, status: 'active', outcome: null, + extraDays: 0, + extensionVotes: players.map(() => null), + official: null, + tally: emptyTally(playerCount), }; } diff --git a/src/engine/state.ts b/src/engine/state.ts index 3219923..c18c79b 100644 --- a/src/engine/state.ts +++ b/src/engine/state.ts @@ -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 ({ + 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; }; // --------------------------------------------------------------------------- diff --git a/src/engine/tally.ts b/src/engine/tally.ts new file mode 100644 index 0000000..4601a02 --- /dev/null +++ b/src/engine/tally.ts @@ -0,0 +1,201 @@ +/** + * The event tally — Gitea#16's statistics, folded from the event stream into `GameState.tally`. + * + * WHERE IT IS HOOKED, and why it is not in `reduce`. `apply.ts`'s `reduce` sees only the events an + * INTENT produced; `advance.ts` mutates state directly and pushes its events without reducing them + * at all — and `advance` is where `trainCompleted`, `trainsDestroyed` and `trainStoodStill` come + * from, which are exactly the numbers this issue asks for. So the fold is hooked at the two places + * every event in the game passes through exactly once on its way to a caller: + * + * - `applyIntent` (`apply.ts`), beside its `reduce` loop; + * - `advance` (`advance.ts`), which now wraps the phase driver and folds what it returns. + * + * Exactly once matters in both directions: an event folded twice inflates a count, and an event + * folded nowhere is a statistic that silently reads zero. `test/tally.test.ts` pins both by playing + * real games and checking the tally against an independent count over the same event array. + * + * NOTHING HERE IS A RULE. The tally is read by the results screen and by the badge work Gitea#16 + * leaves to a second pass; no engine decision consults it. That is what makes adding a counter + * always safe. + * + * WHAT IS NOT COUNTED PER PLAYER, and why. `carsCoupled` and `carsDropped` carry a `trayId` and no + * `player` — switching is done BY a crew, and the event says which crew rather than which person. + * Rather than guess an owner from whose turn it happened to be, those two are table totals only. + * The events that do name a player (`loadCompleted`, `passengersBoarded`, `cardPlayed`, + * `revenueChanged`, `trainsDestroyed`) are the ones `byPlayer` reports. + */ + +import type { GameEvent } from './events.ts'; +import type { GameState } from './state.ts'; + +/** + * Fold one event into `s.tally`. + * + * The switch is deliberately not exhaustive — most of the 49 event types say nothing a player would + * want counted, and listing them all to `break` would bury the ones that do. A `default` that does + * nothing is the honest shape. + */ +export function tallyEvent(s: GameState, e: GameEvent): void { + const t = s.tally; + const mine = 'player' in e && typeof e.player === 'number' ? t.byPlayer[e.player] : undefined; + + switch (e.type) { + /** + * §X18 — the Circus train set up and was paid for the Stage it spent standing. + * + * NOT a "longest stand" streak, which is what Gitea#16 wants and what its comment assumed this + * event was. It fires once in a game per such train: only trains whose profile sets + * `stopEarnsPoint` emit it at all, and `advance.ts` claims it once with `stopPointClaimed`. So + * there is nothing to count a run of, and the honest thing to report is the event itself. + */ + case 'trainStoodStill': + t.circusStops.push({ trainNumber: e.trainNumber, where: e.where }); + break; + + /** + * DID THIS TRAIN DO ANY SWITCHING — Gitea#16's "switching master" join, kept as it happens + * rather than reconstructed afterwards. + * + * The two halves of the join are in different currencies: switching events name a `trayId` and + * completion names a `trainNumber`, and no event carries both. The tray is looked up in LIVE + * state, which is sound precisely here — a crew that has just coupled or dropped is still on the + * board — where re-deriving it at completion time would not be, the tray having been released by + * then. A lookup that misses costs one train its mark on a statistic; it cannot affect a rule. + */ + case 'carsCoupled': + t.carsCoupled += e.stock.length; + markSwitched(s, e.trayId); + break; + + case 'carsDropped': + t.carsDropped += e.stock.length; + markSwitched(s, e.trayId); + break; + + case 'flyingSwitch': + t.flyingSwitches += 1; + markSwitched(s, e.trayId); + break; + + // A tray is reused run after run, so a fresh train starts with a clean sheet. + case 'trainMadeUp': + t.switchedSince = t.switchedSince.filter((n) => n !== e.trainNumber); + break; + + case 'trainCompleted': + t.trainsCompleted += 1; + if (t.switchedSince.includes(e.trainNumber)) t.trainsCompletedWithWork += 1; + t.switchedSince = t.switchedSince.filter((n) => n !== e.trainNumber); + break; + + case 'trainsDestroyed': + t.trainsDestroyed += e.trains.length; + for (const train of e.trains) t.carsDestroyed += train.consist.length; + if (mine) mine.collisions += 1; + break; + + case 'officeUpgraded': + t.officeUpgrades += 1; + break; + + case 'dispatchBonusUsed': + t.dispatchBonusesUsed += 1; + break; + + case 'facilityUnjammed': + t.facilitiesUnjammed += 1; + break; + + case 'expediteFault': + t.expediteFaults += 1; + break; + + case 'trainHeld': + t.trainsHeld += 1; + break; + + case 'trainDiverted': + t.trainsDiverted += 1; + break; + + case 'secondSectionOrdered': + t.secondSections += 1; + break; + + case 'extraStarted': + t.extrasStarted += 1; + break; + + case 'cardDrawn': + t.cardsDrawn += 1; + break; + + case 'cardPlayed': + t.cardsPlayed += 1; + if (mine) mine.cardsPlayed += 1; + break; + + case 'cardDiscarded': + t.cardsDiscarded += 1; + break; + + case 'clearanceRequested': + t.clearancesRequested += 1; + break; + + // §8.1 — a ruling is given either way; only a YES let the other train through. + case 'clearanceGiven': + if (e.allow) t.clearancesAllowed += 1; + break; + + case 'loadStarted': + t.loadsStarted += 1; + break; + + case 'loadCompleted': + t.loadsCompleted += 1; + if (mine) mine.loads += 1; + break; + + case 'unloadBegan': + t.unloadsBegun += 1; + break; + + case 'unloadCompleted': + t.unloadsCompleted += 1; + if (mine) mine.unloads += 1; + break; + + case 'passengersBoarded': + t.passengersBoarded += 1; + if (mine) mine.passengersBoarded += 1; + break; + + case 'passengersDetrained': + t.passengersDetrained += 1; + if (mine) mine.passengersDetrained += 1; + break; + + /** + * Gained and lost are kept APART because the net is already on `players[i].revenue`. What the + * results screen cannot otherwise say is how much of a modest final score was earned and then + * handed back at a grade crossing — which is the whole difference between a quiet game and an + * eventful one. + */ + case 'revenueChanged': + if (mine) { + if (e.delta >= 0) mine.revenueGained += e.delta; + else mine.revenueLost += -e.delta; + } + break; + + default: + break; + } +} + +function markSwitched(s: GameState, trayId: string): void { + const n = s.trays.get(trayId)?.trainNumber; + if (n === undefined || n === null) return; + if (!s.tally.switchedSince.includes(n)) s.tally.switchedSince.push(n); +} diff --git a/src/server/session.ts b/src/server/session.ts index 195320d..ac7fc54 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -22,7 +22,7 @@ import { check } from '../engine/apply.ts'; import { legalActions } from '../engine/legal.ts'; import type { Intent } from '../engine/intents.ts'; import type { GameConfig, PlayerIndex } from '../engine/state.ts'; -import { actionMenu, currentActor, fromMultiplayerSave, newMultiplayerGame, submit } from '../web/game.ts'; +import { actionMenu, currentActor, fromMultiplayerSave, isOutOfTurn, newMultiplayerGame, submit } from '../web/game.ts'; import type { Game, Menu } from '../web/game.ts'; import { deltaFrame } from '../sim/frame-delta.ts'; import type { FrameDelta } from '../sim/frame-delta.ts'; @@ -262,6 +262,52 @@ function buildSession( * in zero wall-clock time by definition. Called once at construction (a resume could land exactly * on a bot's turn) and once after every accepted human intent. */ +/** + * §3.3, EXTENDED PLAY (Gitea#11) — the bots' half of a unanimous vote. + * + * "Bots will not disagree with the human. Humans get to vote first. If all humans vote yes, then + * bots vote yes too. If a human votes no, it's not unanimous, it ends right then. If only bots are + * playing, they never vote to extend" (Jesse, 2026-08-28). + * + * Which makes a bot's vote a formality rather than a policy decision, performed once the humans + * have already settled it, so that the unanimity the engine checks is a real unanimity rather than + * a special case carved into the rules for absent players. + * + * THE ALL-BOT TABLE IS THE CASE TO GET RIGHT, and getting it wrong hung the game. `driveBots` + * cannot reach the vote — it loops on `currentActor`, which is null the moment the game stops — + * so if this returns early with no humans to follow, nobody votes at all and a bot-only game sits + * on the question for ever. It happened: an all-bot session never reached `finished`. With nobody + * to follow, the bots' own answer stands, and it is no. + */ + function driveBotVotes(): void { + if (game.state.status !== 'awaitingExtension') return; + const humans = [...Array(playerNames.length).keys()].filter((p) => !botSeats.has(p)); + // A human who has voted `false` has already ended the game, so reaching here with humans still + // outstanding means the table is genuinely waiting on a person. Bots wait with it. + if (humans.length > 0 && !humans.every((p) => game.state.extensionVotes[p] === true)) return; + const agree = humans.length > 0; + for (const seat of botSeats) { + if (game.state.extensionVotes[seat] === null) { + submit(game, { type: 'game.extend', player: seat, agree }, seat); + } + } + } + + /** + * Bots play, bots vote, and an agreed extension puts them back to playing — so the two drivers + * alternate rather than running once each. Bounded because every pass must consume something: a + * turn, or a vote that cannot be cast twice. + */ + function driveBotTurns(): void { + for (let pass = 0; pass < 1_000; pass++) { + const before = game.history.length; + driveBots(); + driveBotVotes(); + if (game.history.length === before) return; + } + throw new Error('driveBotTurns: probable infinite loop'); + } + function driveBots(): void { let guard = 0; for (;;) { @@ -281,7 +327,7 @@ function buildSession( settleTiming(); } } - driveBots(); + driveBotTurns(); // Whatever the opening bot turns earned belongs to a game nobody was connected to yet — dropped // here rather than fired at the first client to arrive. (It also stops `game.cues` growing without // bound on a server, which nothing was draining before this.) @@ -304,7 +350,17 @@ function buildSession( // never applied, so it is worth trying again (see the `lastSeq.set` below: only on success). if (lastSeq.get(seat) === seq) return { accepted: true, pushes: new Map(), timing: null }; - if (seat !== currentActor(game)) return { accepted: false, code: 'NOT_YOUR_TURN' }; + /** + * §3.3, EXTENDED PLAY (Gitea#11) — the vote is the one intent with no actor to be. + * + * `currentActor` is null once the timetable has run out, so this guard would refuse every + * vote with NOT_YOUR_TURN. Every seat may vote, and `check` is still the authority on whether + * this particular seat may vote right now (it has voted already; the game is not waiting on a + * vote at all), so skipping the turn test here gives nothing away. + */ + if (!isOutOfTurn(i) && seat !== currentActor(game)) { + return { accepted: false, code: 'NOT_YOUR_TURN' }; + } // Checked directly, rather than via `submit`'s boolean, for two reasons: `submit` writes a // "that is not allowed" line into the SHARED `game.log` on rejection, which would otherwise @@ -315,7 +371,7 @@ function buildSession( if (code) return { accepted: false, code }; const drawnBefore = game.justDrawn; - const applied = submit(game, i); + const applied = submit(game, i, isOutOfTurn(i) ? seat : null); if (game.justDrawn !== drawnBefore && game.justDrawn !== null) { lastDraw = { seat, cardId: game.justDrawn }; } @@ -327,8 +383,9 @@ function buildSession( const timing = settleTiming(); // Any bot due to act now plays out entirely before this push goes back — the delta mechanism // diffs against whatever was last sent, so it captures the bots' moves along with the human's - // in one push regardless of how many turns that took. - driveBots(); + // in one push regardless of how many turns that took. Votes included, since Gitea#11: a human + // agreeing to another Day is exactly the move the bots are waiting on to agree themselves. + driveBotTurns(); return { accepted: true, pushes: pushesForAll(), timing }; }, @@ -338,6 +395,8 @@ function buildSession( config: game.state.config, playerNames: [...playerNames], history: [...game.history], + // `awaitingExtension` is a game waiting on its table, not a game that is over — so it maps + // to 'active' and `server/index.ts` resumes it on a restart like any other (Gitea#11). status: game.state.status === 'finished' ? 'finished' : 'active', createdAt, botSeats: [...botSeats], @@ -351,6 +410,8 @@ function buildSession( playerCount: playerNames.length, playerNames: [...playerNames], botSeats: [...botSeats], + // `awaitingExtension` is a game waiting on its table, not a game that is over — so it maps + // to 'active' and `server/index.ts` resumes it on a restart like any other (Gitea#11). status: game.state.status === 'finished' ? 'finished' : 'active', createdAt, lastMoveAt, diff --git a/src/sim/bot.ts b/src/sim/bot.ts index 2ccab69..6d6551f 100644 --- a/src/sim/bot.ts +++ b/src/sim/bot.ts @@ -138,6 +138,19 @@ export function makeDeveloperBot(tweaks: BotTweaks): BotPolicy { choose(s, player, options) { lastReason = 'no specific reason — first legal option'; + + /** + * §3.3, EXTENDED PLAY (Gitea#11) — a bot never asks for another Day. + * + * "If only bots are playing, they never vote to extend" (Jesse, 2026-08-28), which is what keeps + * the balance harness and every bot-only game ending at the timetable it was dealt with. It also + * makes this the SAFE DEFAULT everywhere else: a bot's agreement in a game with humans in it is + * decided by `server/session.ts`, which votes on the bots' behalf only once every human has + * already said yes, and never reaches this policy at all. + */ + const extend = options.find((i) => i.type === 'game.extend' && i.agree === false); + if (extend) return because('a bot plays the timetable it was dealt and no more', extend); + const clearance = ruleOnClearance(options); if (clearance) return because('the Superintendent must rule on a following train', clearance); @@ -1861,6 +1874,36 @@ export function playGame( tally(pumpFn(s)); if (s.status === 'finished') break; + /** + * §3.3, EXTENDED PLAY (Gitea#11) — a simulated game plays the timetable it was dealt. + * + * DECIDED BY THE DRIVER, not by the policy, and that distinction is the whole point. A bot that + * is merely handed the two votes among its legal options will sometimes take another Day — + * `randomBot` does so half the time — and since the table can go on granting Days for ever, the + * game then runs until `maxTurns`. That is not a hypothetical: it turned `test/sim.test.ts` from + * under a second into an unbounded hang, because every seeded game in the harness suddenly played + * fifty thousand turns instead of two hundred. + * + * The harness exists to measure games of a configured length against a configured floor, so + * "would you like more Days?" has one answer here whatever the policy. `developerBot` declines on + * its own account too, which is what the server relies on when a table is all bots; this is the + * guarantee that holds for every OTHER policy, including ones not written yet. + */ + if (s.status === 'awaitingExtension') { + const voter = s.extensionVotes.findIndex((v) => v === null); + if (voter < 0) break; + const decline: Intent = { type: 'game.extend', player: voter, agree: false }; + intents.push(decline.type); + history.push(decline); + const declined = applyIntent(s, voter, decline); + // A broken invariant, not a game ending early: the status says a vote is pending and `voter` is + // a seat that has not cast one. Thrown rather than broken out of, matching the illegal-action + // check below — silently returning a short game is how a dead replay looks like a real one. + if (!declined.ok) throw new Error(`the extension vote was refused with ${declined.code}`); + tally(declined.events); + continue; + } + const actor = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor; if (actor === null) break; diff --git a/src/sim/narrate.ts b/src/sim/narrate.ts index cbb0974..f854c57 100644 --- a/src/sim/narrate.ts +++ b/src/sim/narrate.ts @@ -488,6 +488,16 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration { : { tone: 'good', text: `+${e.delta} Revenue (now ${e.total}) — ${e.reason}` }; case 'phaseEnded': return { tone: 'quiet', text: `Player ${e.player} finished ${phaseLabel(e.phase)}` }; + + // -- §3.3, extended play (Gitea#11) + case 'extensionVoted': + return e.agree + ? { tone: 'plain', text: `Player ${e.player} would play one more Day` } + : { tone: 'plain', text: `Player ${e.player} called time — the game ends here` }; + case 'dayExtended': + return { tone: 'clock', text: `── The table plays on: Day ${e.day} is added to the timetable ──` }; + case 'playConcluded': + return { tone: 'clock', text: '── The railroad is put to bed. Final results stand. ──' }; } } diff --git a/src/sim/save-replay.ts b/src/sim/save-replay.ts index 8919e6b..2d3bd16 100644 --- a/src/sim/save-replay.ts +++ b/src/sim/save-replay.ts @@ -52,6 +52,21 @@ export type PlayedGame = { export function playForReplay(seed: number, policy: BotPolicy, maxTurns = 50_000): PlayedGame { const game = newGame(seed); for (let t = 0; t < maxTurns; t++) { + /** + * §3.3, EXTENDED PLAY (Gitea#11) — a recorded replay is a game played to its end. + * + * The timetable running out leaves the game on "play one more Day?", where `currentActor` is + * null and this loop would otherwise stop — recording a file that replays to a question nobody + * answered rather than to a finished game. A recording bot plays the timetable it was dealt, the + * same rule `playGame` follows, so it declines and the file ends where a real game would. + */ + if (game.state.status === 'awaitingExtension') { + const voter = game.state.extensionVotes.findIndex((v) => v === null); + if (voter < 0) break; + if (!submit(game, { type: 'game.extend', player: voter, agree: false }, voter)) break; + continue; + } + const actor = currentActor(game); if (actor === null) break; const options = legalActions(game.state, actor); diff --git a/src/sim/view.ts b/src/sim/view.ts index 77c12c6..ab64f9c 100644 --- a/src/sim/view.ts +++ b/src/sim/view.ts @@ -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; diff --git a/src/web/game.ts b/src/web/game.ts index ae6e7b3..15ed7da 100644 --- a/src/web/game.ts +++ b/src/web/game.ts @@ -1014,9 +1014,42 @@ export function overHandLimit(game: Game, seat: PlayerIndex = 0): boolean { return hand.length > limit; } -/** Submit an action. Returns false and changes nothing if the engine rejects it. */ -export function submit(game: Game, intent: Intent): boolean { - const actor = currentActor(game); +/** + * Intents any seat may send regardless of whose turn it is — and, for `game.extend`, regardless of + * whether the game is still running at all (§3.3, Gitea#11). + * + * `currentActor` is null once the timetable has run out, which is correct for everything else and + * exactly wrong for the vote on playing another Day. Rather than teach `currentActor` about a state + * where EVERY seat may act at once — which it has no way to express — callers name the seat. + */ +export function isOutOfTurn(intent: Intent): intent is Extract { + return intent.type === 'game.extend'; +} + +/** + * WHO ACTED, when replaying a saved history. + * + * A save is a flat `Intent[]` with no seat written beside each move, so a replay normally derives + * the actor from the turn order — the same order the live game went round in, reproduced exactly. + * That breaks for exactly one intent: the extension vote, which every seat may cast in any order, + * and which `currentActor` answers `null` for because the game has stopped. Replaying such a save + * used to fail outright with `NO_ACTOR`, which is to say an extended game could not be resumed at + * all — found by `test/server/session.test.ts`'s resume test, and the reason `game.extend` carries + * its voter (`intents.ts`). + */ +function replayActor(game: Game, intent: Intent): PlayerIndex | null { + return isOutOfTurn(intent) ? intent.player : currentActor(game); +} + +/** + * Submit an action. Returns false and changes nothing if the engine rejects it. + * + * `as` names the seat for an out-of-turn intent (see `isOutOfTurn`). It cannot be used to smuggle an + * ordinary move past the turn order: `check` is still the authority and still asks `isActor`, so a + * named seat that is not the actor is refused exactly as it would have been. + */ +export function submit(game: Game, intent: Intent, as: PlayerIndex | null = null): boolean { + const actor = as ?? currentActor(game); if (actor === null) return false; const result = applyIntent(game.state, actor, intent); @@ -1167,7 +1200,7 @@ export function undo(game: Game, config: GameConfig = game.state.config): Game | export function fromSave(save: Save, config: GameConfig = SOLO_CONFIG): Game { const game = newGame(save.seed, configFor(save, config)); for (const intent of save.history) { - const actor = currentActor(game); + const actor = replayActor(game, intent); if (actor === null) break; const result = applyIntent(game.state, actor, intent); if (!result.ok) break; @@ -1218,7 +1251,7 @@ export function fromMultiplayerSave( ): { game: Game; stopped: ReplayStop | null } { const game = newMultiplayerGame(seed, config, playerNames); for (const [index, intent] of history.entries()) { - const actor = currentActor(game); + const actor = replayActor(game, intent); if (actor === null) { return { game, stopped: { index, intent, code: 'NO_ACTOR' } }; } diff --git a/src/web/main.ts b/src/web/main.ts index 5306f2a..b98e0db 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -10,7 +10,7 @@ import { TURNCHART_CSS, turnChartHtml } from '../sim/turnchart.ts'; import type { Frame } from '../sim/view.ts'; import { seatLabel } from '../sim/view.ts'; import type { Menu, Save } from './game.ts'; -import { PANEL_CSS, blockedHtml, dayEndHtml, facilitiesHtml, pilesHtml, timetableHtml, yardHtml } from './panels.ts'; +import { PANEL_CSS, blockedHtml, dayEndHtml, facilitiesHtml, pilesHtml, resultsHtml, timetableHtml, yardHtml } from './panels.ts'; import { TOOLTIP_CSS, installTooltips } from './tooltip.ts'; import { playCue } from './sound.ts'; import { @@ -916,7 +916,16 @@ function render(): void { * figure came from a target that is itself an open question. */ const obj = $('objective'); - obj.textContent = `${f.revenue} of ${f.objective.target} · ${f.objective.daysLeft} Day${f.objective.daysLeft === 1 ? '' : 's'} left`; + /** + * Past the original timetable this has to stop saying "N Days left" of a Day count that no longer + * applies (Gitea#11). `objective.daysLeft` already counts against the EXTENDED timetable; what it + * cannot say on its own is that the Days being counted are borrowed ones. + */ + const left = `${f.objective.daysLeft} Day${f.objective.daysLeft === 1 ? '' : 's'} left`; + obj.textContent = + f.extraDays > 0 + ? `${f.revenue} · Day ${f.day} — ${f.extraDays} beyond the timetable` + : `${f.revenue} of ${f.objective.target} · ${left}`; obj.className = 'pace'; // The seed is never sent to a remote client at all (it would leak every future shuffle and roll, // `multiplayer.md` §7) — `RemoteSession` has no `.seed()` because there is nothing to return. @@ -1392,6 +1401,114 @@ function wirePointing(node: HTMLElement, key: string, routeKeys: readonly string node.onblur = off; } +/** + * THE END OF THE GAME — the action area once there is nothing left to decide, or only one thing. + * + * Two states share this, and they are genuinely different (Gitea#11): + * + * - `awaitingExtension` — the timetable ran out on an ending the table MAY play past. The result + * is already recorded and already readable; the only question open is whether to run one more + * Day. In multiplayer that is a unanimous vote, so this also has to show who is still to answer. + * - `finished` — over for good. The results screen, and a new game. + * + * The results are reachable in BOTH, and stay reachable after play continues, which is the + * constraint Gitea#16 and Gitea#11 put on each other: continuing must not cost you the results + * screen, so it is a button that reopens rather than a screen you get one look at. + */ +function renderEnding(el: HTMLElement, f: Frame): void { + const o = f.official?.outcome ?? f.outcome; + const won = o?.result === 'win'; + + /** + * Put the results up once per ending, unasked. + * + * "Once per ENDING" rather than once per game is the extended-play case: an extended game ends, + * is played on, and ends again, and each of those is a moment worth reading. `renderActions` + * clears the flag whenever the game is running again, so the next ending gets its own showing — + * while a redraw during the same ending does not reopen a dialog the player has dismissed. + */ + if (!resultsShown) { + resultsShown = true; + showResults(f); + } + + if (f.status === 'awaitingExtension') { + const mine = f.extensionVotes[f.viewer]; + // Only seats that exist are counted; `extensionVotes` is per PLAYER and so is `players`. + const waiting = f.players.filter((p) => f.extensionVotes[p.index] === null); + const tally = f.players.length > 1 + ? '
' + + f.players + .map((p) => { + const v = f.extensionVotes[p.index]; + const mark = v === true ? '✓' : v === false ? '✗' : '·'; + return `` + + `${mark} ${esc(p.name)}${p.index === f.viewer ? ' (you)' : ''}`; + }) + .join('') + + '
' + : ''; + + el.innerHTML = + `
` + + `${won ? 'THE DIVISION RAN' : 'THE DIVISION FAILED'} — Day ${f.official?.day ?? f.days} is scored.
` + + 'The result above is final. Play one more Day?
' + + tally + + (mine === null + ? '' + + '' + : `
You voted ${mine ? 'to play on' : 'to end it'}. ` + + (waiting.length + ? `Waiting on ${waiting.map((p) => esc(p.name)).join(', ')}.` + : 'Settling…') + + '
') + + ''; + + if (mine === null) { + const vote = (agree: boolean) => () => + void session.submit({ type: 'game.extend', player: f.viewer, agree }); + $('extend-yes').onclick = vote(true); + $('extend-no').onclick = vote(false); + } + $('results').onclick = () => showResults(f); + return; + } + + el.innerHTML = + `
` + + `${won ? 'THE DIVISION RAN' : 'THE DIVISION FAILED'}
` + + `final Revenue ${f.revenue}${f.objective.target > 0 ? ` against a target of ${f.objective.target}` : ''}
` + + '' + + (session.capabilities.newGame ? '' : ''); + + $('results').onclick = () => showResults(f); + if (session.capabilities.newGame) { + $('again').onclick = () => { + clearSave(); + location.search = ''; + }; + } +} + +/** + * Whether the results have been put up by themselves for the ending currently on screen. + * + * Cleared by `renderActions` the moment the game is running again, so an extended game gets a fresh + * showing at each of its endings while a redraw during one ending does not reopen a dialog the + * player has just dismissed. + */ +let resultsShown = false; + +function showResults(f: Frame): void { + const dlg = document.getElementById('resultsdlg') as HTMLDialogElement | null; + const body = document.getElementById('resultsbody'); + if (!dlg || !body) return; + body.innerHTML = resultsHtml(f); + // A redraw can arrive while it is open — `showModal` throws on an already-open dialog rather + // than doing nothing (the same trap `noteDayEnd` documents). + if (!dlg.open) dlg.showModal(); +} + function renderActions( menu: Menu, f: Frame, @@ -1400,18 +1517,12 @@ function renderActions( const el = $('actions'); if (f.status !== 'active') { - const o = f.outcome; - el.innerHTML = - `
` + - `${o?.result === 'win' ? 'YOU WIN' : 'GAME OVER'} — ${esc(String(o?.reason ?? ''))}
` + - `final Revenue ${f.revenue} against a target of ${f.objective.target}
` + - ``; - $('again').onclick = () => { - clearSave(); - location.search = ''; - }; + renderEnding(el, f); return; } + // The game is running, so the next ending — an extended Day's, or a fresh game's — is entitled to + // put its results up unasked again (Gitea#11). + resultsShown = false; if (menu.direct.length === 0 && menu.placeable.length === 0) { el.innerHTML = '
nothing to decide — the engine is running the Division
'; diff --git a/src/web/panels.ts b/src/web/panels.ts index bd1659c..753489d 100644 --- a/src/web/panels.ts +++ b/src/web/panels.ts @@ -168,52 +168,319 @@ export function timetableHtml(f: Frame, justSet: number | null): string { */ export function dayEndHtml(f: Frame): string { const ended = f.day - 1; - const left = f.days - ended; - const standings = [...f.players] - .sort((a, b) => b.revenue - a.revenue || a.seat - b.seat) - .map( - (p) => - `${esc(p.name)}` + - `${p.index === f.viewer ? ' (you)' : ''}` + - `${p.revenue}`, - ) - .join(''); - - // The target is a COMBINED floor in every mode that sets one, so it is reported against the whole - // table's Revenue rather than the viewer's — showing one player's score against a four-player - // target reads as a hopeless position when the table may be comfortably ahead. - const combined = f.players.reduce((n, p) => n + p.revenue, 0); - const target = - f.minCombinedRevenue > 0 - ? `

Combined Revenue ${combined} against a target of ${f.minCombinedRevenue}.

` - : ''; - - /** - * Only when the game is actually scored on collisions. - * - * Two conditions, both of them `advance.ts`'s own: `0` on a dial turns that check off, and the - * checks run in COMPETITIVE AND CO-OP ONLY (§3.4). A solitaire game carries the default dials on - * its config and enforces neither, so reporting a collision budget there would put a rule on - * screen that this game does not have. - */ - const scoredOnCollisions = - (f.mode === 'competitive' || f.mode === 'coop') && - (f.maxCollisionsTotal > 0 || f.maxCollisionsPerDay > 0); - const collisions = scoredOnCollisions - ? `

Collisions: ${f.collisionsToday} today, ${f.collisionsTotal} in all.

` - : ''; + const left = f.days + f.extraDays - ended; const ahead = left <= 0 ? '

That was the last Day on the timetable.

' - : `

Day ${f.day} of ${f.days} begins now — ${left} ${left === 1 ? 'Day' : 'Days'} left to run.

`; + : `

Day ${f.day} of ${f.days + f.extraDays} begins now — ${left} ${left === 1 ? 'Day' : 'Days'} left to run.

`; return ( `

Day ${ended} has ended

` + ahead + - `${standings}
` + - target + - collisions + standingsHtml(f) + + targetHtml(f) + + collisionsHtml(f) + ); +} + +// --------------------------------------------------------------------------- +// Shared between the Day-end dialog and the end-of-game results screen. +// +// Gitea#16 asked for the results screen and Gitea#10's dialog had already assembled most of it. The +// three blocks below are the overlap, factored out rather than written twice: the two screens report +// the same numbers about the same game, and the one thing they must never do is disagree. +// --------------------------------------------------------------------------- + +/** + * Every player in Revenue order, the viewer marked. + * + * `winner` rings the player the OFFICIAL result named, which is not always the player at the top: + * in an extended game the standings keep moving after the result is settled, and showing the leader + * without saying who actually won would be the screen contradicting itself. + */ +function standingsHtml(f: Frame, winner: number | null = null): string { + const rows = [...f.players] + .sort((a, b) => b.revenue - a.revenue || a.seat - b.seat) + .map((p) => { + const marks = + (p.index === f.viewer ? ' (you)' : '') + + (p.index === winner ? ' — winner' : ''); + return ( + `${esc(p.name)}${marks}` + + `${p.revenue}` + ); + }) + .join(''); + return `${rows}
`; +} + +/** + * The target is a COMBINED floor in every mode that sets one, so it is reported against the whole + * table's Revenue rather than the viewer's — showing one player's score against a four-player + * target reads as a hopeless position when the table may be comfortably ahead. + */ +function targetHtml(f: Frame): string { + if (f.minCombinedRevenue <= 0) return ''; + const combined = f.players.reduce((n, p) => n + p.revenue, 0); + const met = combined >= f.minCombinedRevenue; + return ( + `

Combined Revenue ${combined} against a target of ${f.minCombinedRevenue}` + + `${met ? ' — cleared.' : ' — short.'}

` + ); +} + +/** + * Only when the game is actually scored on collisions. + * + * Two conditions, both of them `advance.ts`'s own: `0` on a dial turns that check off, and the + * checks run in COMPETITIVE AND CO-OP ONLY (§3.4). A solitaire game carries the default dials on + * its config and enforces neither, so reporting a collision budget there would put a rule on + * screen that this game does not have. + */ +function collisionsHtml(f: Frame): string { + const scoredOnCollisions = + (f.mode === 'competitive' || f.mode === 'coop') && + (f.maxCollisionsTotal > 0 || f.maxCollisionsPerDay > 0); + return scoredOnCollisions + ? `

Collisions: ${f.collisionsToday} today, ${f.collisionsTotal} in all.

` + : ''; +} + +/** + * WHY THE GAME ENDED, as a sentence (Gitea#16). + * + * The page used to interpolate `outcome.reason` straight into the DOM, so a player who finished a + * game read the words `GAME OVER — revenueFloor`: an internal enum value, printed at the one moment + * the game has the player's whole attention. Each reason gets a sentence that says what actually + * happened, with this game's own numbers in it. + */ +function reasonSentence(f: Frame, o: NonNullable, day: number): string { + const combined = f.players.reduce((n, p) => n + p.revenue, 0); + switch (o.reason) { + case 'daysElapsed': + return `Day ${day} was the last on the timetable, and it ran out.`; + case 'revenueFloor': + return ( + `The Division closed short: ${combined} Revenue between everyone, against a floor of ` + + `${f.minCombinedRevenue}. §3.3 — miss the floor and the whole table loses, whoever ` + + `earned the most.` + ); + case 'collisionFloor': + return ( + `Too many collisions — ${f.collisionsToday} in one Day and ${f.collisionsTotal} ` + + `in all, against limits of ${f.maxCollisionsPerDay || '—'} and ${f.maxCollisionsTotal || '—'}. ` + + `§3.4 — the railroad was declared unsafe and the game was stopped.` + ); + } +} + +/** The rules this game was actually dealt under — Gitea#16's "what the rules of the game were". */ +function rulesHtml(f: Frame): string { + const mode = + f.mode === 'coop' ? 'Co-op — the table scores together' : + f.mode === 'competitive' ? 'Competitive — highest Revenue wins' : + 'Solitaire'; + const optional = [ + f.optionalRules.employeeRotation ? 'Employee Rotation' : null, + f.optionalRules.reducedVisibility ? 'Reduced Visibility' : null, + f.optionalRules.emergencyToolbox ? 'Emergency Toolbox' : null, + ].filter((x): x is string => x !== null); + const r = f.houseRules.revenue; + + const rows: [string, string][] = [ + ['Scoring', mode], + ['Timetable', f.extraDays > 0 + ? `${f.days} Days, extended by ${f.extraDays} more` + : `${f.days} Day${f.days === 1 ? '' : 's'}`], + ['Revenue floor', f.minCombinedRevenue > 0 ? `${f.minCombinedRevenue} combined` : 'none'], + ['Collision limits', f.maxCollisionsPerDay > 0 || f.maxCollisionsTotal > 0 + ? `${f.maxCollisionsPerDay || '—'} per Day, ${f.maxCollisionsTotal || '—'} in all` + : 'not scored'], + ['Pay rates', `${r.freightPerLoad} per load, ${r.passengerPerCoach} per coach, ${r.trainPerTransit} per transit`], + ['Extras start', f.houseRules.extraStart === 'divisionPointsOnly' ? 'Division Points and the Interchange' + : f.houseRules.extraStart === 'ownOffice' ? 'those, plus your own Control Point' + : 'those, plus any Control Point'], + ['Timetabled trains', f.houseRules.discardTimetabled ? 'may be discarded' : 'are never discarded'], + ['Optional rules', optional.length ? optional.join(', ') : 'none'], + ]; + + return `

The rules in play

${factTable(rows)}`; +} + +/** + * A two-column table of plain-text facts. + * + * BOTH HALVES ESCAPED, exactly once, which is the only reason this is a shared helper rather than a + * template repeated twice. The rows it is given today are numbers and fixed phrases, but they are + * assembled from the Frame — and the day somebody adds a row carrying a player's name, or a facility + * label, the escaping has to already be here rather than be remembered. + */ +function factTable(rows: [string, string][]): string { + return ( + '' + + rows.map(([k, v]) => ``).join('') + + '
${esc(k)}${esc(v)}
' + ); +} + +/** + * THE RAILROAD — what actually happened out there, from `GameState.tally` (Gitea#16). + * + * Rows that would read zero for a reason (no passenger work in a game that had none, no collisions + * in a clean one) are dropped rather than printed as `0`: a screen of zeroes reads as a bug, and the + * absence of a line is the same information more quietly. A zero that is genuinely interesting — + * trains through the Division — stays. + */ +function tallyHtml(t: Frame['tally']): string { + const rows: [string, string][] = [['Trains through the Division', String(t.trainsCompleted)]]; + + if (t.trainsCompleted > 0) { + rows.push([ + 'Of those, worked en route', + `${t.trainsCompletedWithWork} of ${t.trainsCompleted}` + + (t.trainsCompletedWithWork === t.trainsCompleted ? ' — every one' : ''), + ]); + } + const push = (label: string, n: number, detail = ''): void => { + if (n > 0) rows.push([label, `${n}${detail}`]); + }; + push('Loads made up', t.loadsCompleted); + push('Loads broken', t.unloadsCompleted); + push('Loads still in the pipeline', t.loadsStarted - t.loadsCompleted); + push('Passengers boarded', t.passengersBoarded); + push('Passengers detrained', t.passengersDetrained); + push('Cars coupled', t.carsCoupled); + push('Cars set out', t.carsDropped); + push('Extras run', t.extrasStarted); + push('Second sections ordered', t.secondSections); + push('Flying switches', t.flyingSwitches); + push('Offices upgraded', t.officeUpgrades); + push('Facilities unjammed', t.facilitiesUnjammed); + push('Trains held', t.trainsHeld); + push('Trains diverted', t.trainsDiverted); + push('Expedite faults', t.expediteFaults); + push('Dispatch bonuses used', t.dispatchBonusesUsed); + if (t.clearancesRequested > 0) { + rows.push(['Clearances', `${t.clearancesAllowed} allowed of ${t.clearancesRequested} asked`]); + } + if (t.trainsDestroyed > 0) { + rows.push([ + 'Trains destroyed', + `${t.trainsDestroyed}, taking ${t.carsDestroyed} car${t.carsDestroyed === 1 ? '' : 's'} with them`, + ]); + } + // §X18 only — the one card in the deck that pays a train for standing still. Reported as what it + // is rather than as "the longest an engine sat on a siding", which the engine cannot answer (see + // `Tally.circusStops`). + if (t.circusStops.length > 0) { + rows.push([ + 'Circus set-ups', + t.circusStops.map((c) => `Train ${c.trainNumber} at ${c.where}`).join(', '), + ]); + } + push('Cards drawn', t.cardsDrawn); + push('Cards played', t.cardsPlayed); + + return `

The railroad

${factTable(rows)}`; +} + +/** Per-player work, for a table that wants to know who did what rather than only who won. */ +function perPlayerHtml(f: Frame): string { + const t = f.tally; + if (f.players.length < 2) return ''; + const head = + 'RevLoadsUnloads' + + 'Pass.CardsCrashes'; + const rows = [...f.players] + .sort((a, b) => b.revenue - a.revenue || a.seat - b.seat) + .map((p) => { + const q = t.byPlayer[p.index]; + if (!q) return ''; + return ( + `${esc(p.name)}` + + `${p.revenue}${q.loads}` + + `${q.unloads}` + + `${q.passengersBoarded + q.passengersDetrained}` + + `${q.cardsPlayed}${q.collisions}` + ); + }) + .join(''); + return `

Who did what

${head}${rows}
`; +} + +/** + * THE END-OF-GAME RESULTS SCREEN — Gitea#16, first pass. + * + * Everything the Frame already knew plus everything the event tally counted, in the order a player + * asks for it: what happened, who won, by how much, under what rules, and then what the railroad + * actually did all game. Badges and the "what would make this exciting" brainstorm are the second + * pass the issue asks for and are deliberately not here. + * + * THE OFFICIAL RESULT IS THE ONE AT THE TOP, always. In an extended game (Gitea#11) the standings go + * on moving after the winner is settled, so this screen reports the frozen result first and puts + * everything that happened afterwards in its own section, marked as informational. "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). + */ +export function resultsHtml(f: Frame): string { + // `official` is written by the engine the moment any game ends, so it is present on every finished + // game. The fallback keeps this rendering something sane for a Frame that predates it — a replay + // of a save recorded before this release, which the replay viewer will happily hand us. + const report = f.official; + const o = report?.outcome ?? f.outcome; + if (!o) return '

This game has not ended.

'; + + const officialDay = report?.day ?? f.days; + const winnerName = + o.winner === null ? null : (f.players.find((p) => p.index === o.winner)?.name ?? null); + + const headline = + o.result === 'loss' + ? 'The Division failed' + : winnerName === null + ? 'The Division ran' + : `${esc(winnerName)} takes the Division`; + + /** + * The result is reported against the standings AS THEY WERE at the official ending, not as they + * are now — in an extended game those are different numbers, and the winner has to be shown + * winning. `revenues` is frozen alongside the outcome for exactly this. + */ + const frozen = report + ? { ...f, players: f.players.map((p) => ({ ...p, revenue: report.revenues[p.index] ?? p.revenue })) } + : f; + + const result = + o.result === 'loss' + ? '

Nobody wins this one.

' + : o.winner === null + ? '

The table clears it together — a Co-op game has no individual winner.

' + : `

${esc(winnerName ?? '')} finishes ahead on Revenue.

`; + + const extended = + f.extraDays > 0 + ? '

After the timetable

' + + `

The table played on for ${f.extraDays} more Day${f.extraDays === 1 ? '' : 's'}, ` + + `through Day ${f.days + f.extraDays}. None of it changed the result above — it is recorded ` + + 'here because it happened.

' + + standingsHtml(f) + + targetHtml(f) + + collisionsHtml(f) + + tallyHtml(f.tally) + : ''; + + return ( + `

${headline}

` + + `

${reasonSentence(frozen, o, officialDay)}

` + + result + + standingsHtml(frozen, o.winner) + + targetHtml(frozen) + + collisionsHtml(frozen) + + perPlayerHtml(frozen) + + rulesHtml(f) + + tallyHtml(report?.tally ?? f.tally) + + extended ); } @@ -479,4 +746,20 @@ ul.blocked{margin:0;padding-left:18px} .dayend-t tr:first-child td{border-top:0} .dayend-t .num{text-align:right;font-variant-numeric:tabular-nums;font-weight:700;padding-right:0} .dayend-t .you td{color:#8fd6a0} +.dayend-t .wins{color:#e8c56a;font-weight:700} + +/* END-OF-GAME RESULTS (Gitea#16). Same family as the Day-end dialog above, which is the point — + the two screens share their standings/target/collision blocks and should look like each other. */ +.res-h{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#8b95a3; + margin:16px 0 6px;border-top:1px solid #2c333d;padding-top:10px} +.res-win{color:#8fd6a0} +.res-loss{color:#d98f8f} +.res-t{border-collapse:collapse;margin:4px 0;width:100%} +.res-t td,.res-t th{padding:3px 12px 3px 0;border-top:1px solid #232a33;vertical-align:top} +.res-t tr:first-child td{border-top:0} +.res-t td:first-child{color:#8b95a3;white-space:nowrap} +.res-t th{color:#6d7783;font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.05em} +.res-t .num{text-align:right;font-variant-numeric:tabular-nums} +.res-wide td:first-child{color:#e6e9ee} +.res-t .you td{color:#8fd6a0} `; diff --git a/src/web/play.html b/src/web/play.html index 1886362..abf3bfa 100644 --- a/src/web/play.html +++ b/src/web/play.html @@ -222,6 +222,14 @@ h3.actions-hd{font-size:13px;text-transform:none;letter-spacing:.01em;color:#cfe .over{padding:9px;border-radius:5px;font-weight:700;margin-bottom:8px} .over.win{background:rgba(40,140,60,.35)} .over.loss{background:rgba(160,60,60,.3)} +/* THE EXTENSION VOTE (Gitea#11) — unanimous, so who has not answered yet is the useful half. */ +.vote-tally{display:flex;flex-wrap:wrap;gap:4px 12px;margin:0 0 9px;font-size:12px} +.vote.yes{color:#8fd6a0} +.vote.no{color:#d98f8f} +.vote.wait{color:var(--dim)} +/* Wider than the New Game dialog: the results carry a seven-column per-player table (Gitea#16). */ +#resultsdlg{max-width:640px} +#resultsdlg table{max-width:100%} /* cards, log, blocked */ .card.gone{opacity:.35;text-decoration:line-through} .subj{display:block;width:100%;margin:2px 0} @@ -890,6 +898,18 @@ ul.blocked li{padding:2px 0} + + +
+
+ + + +
+
+