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} + + +
+
+ + + +
+
+