Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45580d8b61 | ||
|
|
510e33bac7 | ||
|
|
c4a08433ba |
+110
@@ -19,6 +19,116 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
||||
|
||||
---
|
||||
|
||||
## 0.7.3 — 2026-08-29
|
||||
|
||||
Two issues off the tracker, and they are halves of one thing: the end of a game. Gitea#11 stops the
|
||||
game being over when the timetable runs out, and Gitea#16 replaces the four lines that were shown
|
||||
there with a results screen worth reading. Neither ships on the 0.4.9 line — Jesse's call
|
||||
(2026-08-29): that line may be complete, and these are not fixes people mid-playtest need.
|
||||
|
||||
### The game asks before it ends (Gitea#11)
|
||||
|
||||
"When game ends allow players to continue playing if they wish… don't force end."
|
||||
|
||||
The rule, decided with Jesse: **the official result is settled at the original game length and never
|
||||
changes.** In a five-Day game extended to eight, the winner is whoever led at the end of Day 5.
|
||||
Extending grants exactly **one** Day and the question is put again at the end of it — solitaire the
|
||||
player decides alone, multiplayer it is unanimous and one refusal ends it there and then. Only
|
||||
days-based endings offer it: a §3.4 collision breach is final, during an extended Day exactly as
|
||||
during the scheduled game, because a railroad declared unsafe does not carry on regardless.
|
||||
|
||||
**It could not be a client-side change**, for three reasons that each rule out the others' fixes.
|
||||
`check` refused every intent once `status` left `active`; `server/index.ts` never loads a `finished`
|
||||
game back into memory; and a save is `{ seed, config, history }` replayed through the engine, so a
|
||||
"continue" the history does not record did not happen — the extended game would evaporate on the next
|
||||
reload, Undo or restart. So there is a fourth status, `awaitingExtension`, and the vote is an intent.
|
||||
|
||||
`config.days` never moves. `extraDays` counts the borrowed Days, and `official` — the outcome, the
|
||||
standings and the statistics, frozen at the first ending — is what the results screen reports. That
|
||||
freeze is done by a wrapper around `advance` rather than inside `checkVictory`, so it happens *after*
|
||||
the last Day's events have been counted rather than before them.
|
||||
|
||||
**Three bugs found while testing it, all of which would have shipped:**
|
||||
|
||||
- **A saved game with a vote in it could not be resumed** — `NO_ACTOR`. A history is a flat
|
||||
`Intent[]` with no seat written down; the replay derives who acted from the turn order. That works
|
||||
for every other intent, `mainline.clearance` included, because there is exactly one seat it could
|
||||
have been. Not here: every seat may vote in any order. `game.extend` therefore carries its voter,
|
||||
uniquely, and the server checks it against the seat it authenticated.
|
||||
- **An all-bot game hung on the question for ever.** `driveBots` loops on `currentActor`, which is
|
||||
null the moment the game stops, so it cannot cast a vote; the bot-vote driver returned early when
|
||||
there were no humans to follow, and nothing ever asked. With nobody to follow, the bots' own answer
|
||||
stands — no — and a bot-only game ends on the timetable it was dealt.
|
||||
- **The balance harness became unbounded**, which is how the third one announced itself:
|
||||
`test/sim.test.ts` went from under a second to never finishing. `randomBot` picks uniformly among
|
||||
its legal options, so once the two votes were among them it took another Day about half the time —
|
||||
and because a table may go on granting Days indefinitely, every seeded game ran to `playGame`'s
|
||||
50,000-turn cap instead of a couple of hundred. Giving `developerBot` a policy was not enough: the
|
||||
guarantee has to hold for every policy, so **`playGame` itself declines**. A simulated game plays
|
||||
the timetable it was dealt, whatever the bot would have voted.
|
||||
|
||||
All three have regression tests.
|
||||
|
||||
Bots never lead. They agree only once every human has agreed, which is the rule Jesse set: "bots will
|
||||
not disagree with the human. Humans get to vote first."
|
||||
|
||||
### The results screen (Gitea#16)
|
||||
|
||||
`GAME OVER — revenueFloor` was not a message. It was `outcome.reason`, an internal enum, interpolated
|
||||
straight into the page at the one moment the game has the player's whole attention. Every reason now
|
||||
has a sentence with the game's own numbers in it, and that fix alone answers the issue's "why did the
|
||||
game end?".
|
||||
|
||||
Around it: the result and the winner, the standings, the rules the game was actually dealt under, a
|
||||
per-player breakdown, and **the railroad** — trains through the Division and how many of them did any
|
||||
switching en route, loads made up and broken, passengers worked, cars switched, trains destroyed and
|
||||
what they took with them. It shares the Day-end dialog's standings, target and collision blocks rather
|
||||
than reimplementing them, because two screens reporting the same game must not be able to disagree.
|
||||
It opens itself once per ending and leaves a button to reopen it, which is what stops Gitea#11's
|
||||
extended play costing you the results.
|
||||
|
||||
**"I don't know if we keep statistics on…"** — nothing was being kept, and now `state.tally` is,
|
||||
folded from the event stream. Hooked at `applyIntent` and at `advance`, because `reduce` never sees
|
||||
the phase driver's events and those are exactly the interesting ones: `trainCompleted`,
|
||||
`trainsDestroyed`, `trainStoodStill`. The test that matters asserts **exactly once** — it plays real
|
||||
games, counts the event stream independently, and checks the tally against that count, so a fold
|
||||
hooked twice or nowhere fails whatever the seed.
|
||||
|
||||
Nothing in the rules reads the tally, so adding a counter is always safe. It rides the `Frame`, so
|
||||
multiplayer gets the same numbers as solitaire from one implementation — and `test/redaction.test.ts`
|
||||
gained a case proving a tally never carries a card id, since that is a property of what `tally.ts`
|
||||
chooses to count and not something the types enforce.
|
||||
|
||||
**"Longest an engine sat on a siding" is not in this pass, and the issue comment was wrong about
|
||||
why it could be.** That comment said `trainStoodStill` "is emitted per Stage, so a run of them is
|
||||
exactly the 'sat on a siding' streak you describe". It is not. Reading `advance.ts` rather than
|
||||
trusting it: the event fires only for a train whose profile sets `stopEarnsPoint` — the X18 Circus,
|
||||
the one card in the deck that pays for standing still — and `stopPointClaimed` makes sure it can
|
||||
never fire twice for the same train. The streak was built, shipped nothing but "1 Stage" against a
|
||||
raw grid coordinate, and has been taken out again. The engine has **no per-Stage "this train did not
|
||||
move" signal at all**, so this needs one before it can be answered; `TODO.md` #36 records that. What
|
||||
is reported instead is the Circus set-up itself, which is a real thing that happened.
|
||||
|
||||
**Badges are not here.** The issue asks for them in a second pass after "a whole conversation
|
||||
brainstorming session", and that is where they belong. The tally keeps the raw material — the
|
||||
switching join for "switching master", the standing runs for "longest engine sat on a siding" — and
|
||||
because the statistics are *derived* rather than recorded, a second pass can add any of them
|
||||
retroactively to games already played and saved.
|
||||
|
||||
### Also
|
||||
|
||||
- **A recorded replay plays to its end.** `save-replay.ts` stopped where `currentActor` went null,
|
||||
which since Gitea#11 is the extension question — so a newly recorded file would have replayed to a
|
||||
question nobody answered rather than to a finished game. It declines, like every other bot driver.
|
||||
The three files already published in `public/replays` predate the vote and stop on the question
|
||||
when replayed; `test/harness.test.ts` accepts that as the end of their history, since it is.
|
||||
- The status line stops lying past the last Day. It read `N of TARGET · D Days left` with both halves
|
||||
false; in an extended game it now reads the Day and how far beyond the timetable play has got.
|
||||
- `objectiveOf` paces against the timetable actually being played rather than `config.days`, which
|
||||
otherwise reported "the last Day is over" through every extended Day.
|
||||
|
||||
---
|
||||
|
||||
## 0.7.2 — 2026-08-26
|
||||
|
||||
Five issues off the tracker. Two are engine bugs a player hit at the board, two are the design
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -164,25 +164,76 @@ pass as 19-21 and 24 above, and all three are answered by Gitea#18 rather than f
|
||||
Queued 2026-08-25, from releasing 0.7.1 / 0.4.9g. **Both are blocked on the two commits being made
|
||||
and pushed** — they were still uncommitted when the session ended.
|
||||
|
||||
30. **Close the Gitea issues by hand, each with a comment naming the commit that fixed it.** Raised
|
||||
by Jesse 2026-08-25. `Closes #<n>` is in the main commit message and will auto-close #2, #8, #9
|
||||
and #10 when it reaches the default branch, but auto-closing leaves the issue with no record of
|
||||
WHICH commit or WHICH release answered it — and #9 supersedes #6, which nothing on #6 will ever
|
||||
say. So each issue wants a comment of its own: the commit SHA on both lines, the version each
|
||||
shipped in (0.7.1 and 0.4.9g), and for #2 and #9 the ruling that decided it, since neither was
|
||||
fixed the way the report implies. #6 wants a comment too, pointing at #9. The token and the API
|
||||
calls are in the workspace's `AGENTS.local.md`.
|
||||
30. ~~**Close the Gitea issues by hand, each with a comment naming the commit that fixed it.**~~ —
|
||||
done, and done again for v0.7.2 / v0.4.9h (2026-08-26). #2, #6, #8, #9 and #10 carry their
|
||||
comments from the v0.7.1 release, including the pointer on #6 saying #9 superseded it. #3, #14,
|
||||
#15, #17 and #18 now carry theirs, each naming `2ab25e3` and `e9683cc` and the version each
|
||||
shipped in — and, where the fix was not what the report implied, the ruling that decided it:
|
||||
#15 was REVERSED on review (the placement is legal; what was confirmed is that no train crosses
|
||||
the gap), and #14's comment lists the ten unbuilt cards that are held out, so they do not vanish
|
||||
along with the issue.
|
||||
|
||||
31. **Bump the StartOS wrapper to 0.7.1.** `station-master-startos` is clean, on `master`, pinned to
|
||||
`v0.7.0` and versioned `0.7.0:3`. Once v0.7.1 is tagged: move the submodule pin to the tag, set
|
||||
`startos/versions/current.ts` to **`0.7.1:0`** (a new upstream tag resets the downstream digit —
|
||||
`UPDATING.md`), and write release notes in all five locales. **No new version FILE and no
|
||||
migration** — `current.ts`'s `migrations.up` is empty, so nothing is carried from 0.7.0, and a
|
||||
0.7.0 save replays under 0.7.1 unchanged (its intents cannot contain a Timetabled discard,
|
||||
because that was illegal when they were recorded). `instructions.md` and `README.md` both name
|
||||
0.7.0 and both need the three user-visible changes: the Day-end dialog, the Timetabled discard
|
||||
and its New Game checkbox, and the reasons a blocked platform now gives. Remember the branch
|
||||
trap: the game pushes to `main`, the wrapper to `master`.
|
||||
**Keep doing this.** Auto-closing leaves an issue with no record of which commit or which release
|
||||
answered it. The token and the API calls are in the workspace's `AGENTS.local.md`.
|
||||
|
||||
31. ~~**Bump the StartOS wrapper to 0.7.2.**~~ — done 2026-08-26 (`1bfea8d`). Submodule pinned to
|
||||
`v0.7.2`, `current.ts` at `0.7.2:0`, release notes rewritten in all five locales, `README.md` and
|
||||
`instructions.md` updated. No new version file and no migration. Verified: `npm run check` clean
|
||||
and `make x86` packs as `v0.7.2:0`.
|
||||
|
||||
**This is the first release that does NOT carry games in progress**, and the release notes lead
|
||||
with it in every locale. A save is a seed plus the moves played; the deck going from 206 cards to
|
||||
121 means a card id recorded under 0.7.1 refers to a different card or to none, so a save stops
|
||||
replaying at its first `card.play`. There is nothing to migrate — those moves were made against a
|
||||
deck that no longer exists — and it fails safe: `src/server/index.ts` refuses to resume a save the
|
||||
rules reject, names the move it stopped at, and leaves the file untouched, so an operator can put
|
||||
0.7.1 back on to finish a game that matters.
|
||||
|
||||
32. **Tell the 0.4.9 playtesters their saves are dead, before they find out.** The same deck change
|
||||
shipped as v0.4.9h, so every save filed before it — including the ones attached to Gitea#15 and
|
||||
#17 — stops replaying at its first `card.play`, and any game a tester has in progress will be
|
||||
declined on restart. They fail safe and the files are kept, but nobody has been told. Worth a
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+94
-10
@@ -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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
+18
-1
@@ -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'];
|
||||
|
||||
+27
-2
@@ -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 };
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
+5
-1
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+220
-1
@@ -684,6 +684,139 @@ export type Outcome = {
|
||||
reason: OutcomeReason;
|
||||
};
|
||||
|
||||
/**
|
||||
* §3.3, EXTENDED PLAY (Gitea#11) — which endings may be played past.
|
||||
*
|
||||
* Both days-based endings offer another Day: running out of timetable, and closing short of the
|
||||
* combined Revenue floor, are the same event seen twice — the last Day ended and this is what the
|
||||
* books say. A `collisionFloor` ending is NOT extendable, and neither is a collision breach that
|
||||
* happens during an extended Day: §3.4 stopped the game because the railroad was declared unsafe,
|
||||
* and carrying on regardless would contradict the rule that stopped it (Jesse's call, 2026-08-28).
|
||||
*/
|
||||
export function isExtendable(reason: OutcomeReason): boolean {
|
||||
return reason === 'daysElapsed' || reason === 'revenueFloor';
|
||||
}
|
||||
|
||||
/**
|
||||
* Running counts of everything interesting that has happened, tallied from the event stream
|
||||
* (Gitea#16).
|
||||
*
|
||||
* WHY IT LIVES ON `GameState` rather than being computed by whoever happens to want it. Three
|
||||
* reasons, in ascending order of how much they cost to work around:
|
||||
*
|
||||
* 1. `snapshot()` already takes a `GameState`, so every number here reaches a MULTIPLAYER client
|
||||
* through the `Frame` it is already being sent — no new server route, no new `Push` field, no
|
||||
* new `Session` method, and no second implementation that can disagree with the first.
|
||||
* 2. It is REPLAY-EXACT. A save is `{ seed, config, history }` replayed through the engine
|
||||
* (`web/game.ts`'s `fromSave`), so a tally folded from the events that replay emits is rebuilt
|
||||
* identically every time — which is what makes Undo and a server restart correct here for free.
|
||||
* 3. The official result freezes a COPY of this at the moment the timetable ran out (`official`
|
||||
* below), and a frozen copy has to be taken from something that already exists.
|
||||
*
|
||||
* Aggregate counts only. Nothing here is seat-secret — no card ids, no hands — which is why
|
||||
* `test/redaction.test.ts` stays green with the whole thing on the Frame.
|
||||
*
|
||||
* NOT SCORING. Nothing in here feeds a rule; it is read by the results screen and by the badge work
|
||||
* that Gitea#16 leaves to a second pass. Adding a counter is always safe.
|
||||
*/
|
||||
export type Tally = {
|
||||
/** §8.3 — a train that ran the length of the Division and left it. */
|
||||
trainsCompleted: number;
|
||||
/**
|
||||
* Of those, how many did some switching between being made up and leaving.
|
||||
*
|
||||
* The join Gitea#16 asks for by name ("a player who completes an entire game where every train
|
||||
* that passed through did some switching on"). Counted as the train completes, against whether
|
||||
* that tray has coupled or dropped anything since it was made up — which is why `switchedSince`
|
||||
* below exists rather than this being derivable afterwards.
|
||||
*/
|
||||
trainsCompletedWithWork: number;
|
||||
/** §10 — trains lost to a collision, and the cars that went with them. */
|
||||
trainsDestroyed: number;
|
||||
carsDestroyed: number;
|
||||
/** §6 — switching volume, both directions. */
|
||||
carsCoupled: number;
|
||||
carsDropped: number;
|
||||
/** §9.1 — the MEN | AT | WORK pipeline: begun, and carried all the way through. */
|
||||
loadsStarted: number;
|
||||
loadsCompleted: number;
|
||||
unloadsBegun: number;
|
||||
unloadsCompleted: number;
|
||||
/** §9.2 — passenger work. */
|
||||
passengersBoarded: number;
|
||||
passengersDetrained: number;
|
||||
/** Colour, and the vocabulary the badge pass will draw on. */
|
||||
flyingSwitches: number;
|
||||
officeUpgrades: number;
|
||||
dispatchBonusesUsed: number;
|
||||
facilitiesUnjammed: number;
|
||||
expediteFaults: number;
|
||||
trainsHeld: number;
|
||||
trainsDiverted: number;
|
||||
secondSections: number;
|
||||
extrasStarted: number;
|
||||
cardsDrawn: number;
|
||||
cardsPlayed: number;
|
||||
cardsDiscarded: number;
|
||||
clearancesRequested: number;
|
||||
/** §8.1 — rulings that let the other train through. A refusal is a ruling too, but not this one. */
|
||||
clearancesAllowed: number;
|
||||
/**
|
||||
* X18 CIRCUS SET-UPS — a train that spent a Stage standing still and was paid for it (§X18).
|
||||
*
|
||||
* NOT "the longest an engine sat on a siding", which is what Gitea#16 asks for and what the
|
||||
* comment on that issue assumed this was. `trainStoodStill` is emitted ONCE IN A GAME PER SUCH
|
||||
* TRAIN — only for a train whose profile has `stopEarnsPoint`, and `advance.ts` sets
|
||||
* `stopPointClaimed` so it can never fire twice. There is no per-Stage "this train did not move"
|
||||
* signal in the engine at all, so a longest-stand streak cannot be folded from the event stream:
|
||||
* it needs an engine-side signal that does not exist yet. Recorded in `TODO.md` for the badge
|
||||
* pass rather than shipped as a statistic that would read "1 Stage" for ever.
|
||||
*/
|
||||
circusStops: { trainNumber: number; where: string }[];
|
||||
/**
|
||||
* Train numbers that have coupled or dropped something since they were made up, for
|
||||
* `trainsCompletedWithWork`. Cleared when the train is made up and when it leaves the Division.
|
||||
*/
|
||||
switchedSince: number[];
|
||||
/** Indexed by PLAYER. Only events that name a player reach these. */
|
||||
byPlayer: PlayerTally[];
|
||||
};
|
||||
|
||||
export type PlayerTally = {
|
||||
loads: number;
|
||||
unloads: number;
|
||||
passengersBoarded: number;
|
||||
passengersDetrained: number;
|
||||
cardsPlayed: number;
|
||||
/** §10 — collisions this player was faulted for, not collisions they were caught in. */
|
||||
collisions: number;
|
||||
/** Revenue gained and Revenue lost, kept apart: the net is already on `players[i].revenue`. */
|
||||
revenueGained: number;
|
||||
revenueLost: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* THE OFFICIAL RESULT, frozen at the moment the timetable ran out (Gitea#11).
|
||||
*
|
||||
* "The winner is based upon the original game length. In a five-day game, even if it's extended to
|
||||
* eight or nine days, the winner and the official answer is the winner at the end of five days"
|
||||
* (Jesse, 2026-08-28). So this is written ONCE, at the first ending, and never overwritten —
|
||||
* including by a §3.4 collision breach during an extended Day, which ends play without touching it.
|
||||
*
|
||||
* `state.outcome` keeps moving: it is always the CURRENT evaluation, which is what the live game
|
||||
* wants. Once `official` exists, everything after it is informational.
|
||||
*/
|
||||
export type FinalReport = {
|
||||
/** The Day the game was scheduled to end on — always `config.days`. */
|
||||
day: number;
|
||||
outcome: Outcome;
|
||||
/** Every player's Revenue at that moment, in player order. */
|
||||
revenues: number[];
|
||||
collisionsTotal: number;
|
||||
/** The Tally as it stood when the timetable ran out. */
|
||||
tally: Tally;
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-Stage transient bookkeeping for the acting player. Reset when the actor changes.
|
||||
*
|
||||
@@ -726,6 +859,66 @@ export function freshTurns(players: number, moves: number): Map<PlayerIndex, Tur
|
||||
return turns;
|
||||
}
|
||||
|
||||
/** A Tally with everything at zero — the state every game starts in (Gitea#16). */
|
||||
export function emptyTally(players: number): Tally {
|
||||
return {
|
||||
trainsCompleted: 0,
|
||||
trainsCompletedWithWork: 0,
|
||||
trainsDestroyed: 0,
|
||||
carsDestroyed: 0,
|
||||
carsCoupled: 0,
|
||||
carsDropped: 0,
|
||||
loadsStarted: 0,
|
||||
loadsCompleted: 0,
|
||||
unloadsBegun: 0,
|
||||
unloadsCompleted: 0,
|
||||
passengersBoarded: 0,
|
||||
passengersDetrained: 0,
|
||||
flyingSwitches: 0,
|
||||
officeUpgrades: 0,
|
||||
dispatchBonusesUsed: 0,
|
||||
facilitiesUnjammed: 0,
|
||||
expediteFaults: 0,
|
||||
trainsHeld: 0,
|
||||
trainsDiverted: 0,
|
||||
secondSections: 0,
|
||||
extrasStarted: 0,
|
||||
cardsDrawn: 0,
|
||||
cardsPlayed: 0,
|
||||
cardsDiscarded: 0,
|
||||
clearancesRequested: 0,
|
||||
clearancesAllowed: 0,
|
||||
circusStops: [],
|
||||
switchedSince: [],
|
||||
byPlayer: Array.from({ length: players }, () => ({
|
||||
loads: 0,
|
||||
unloads: 0,
|
||||
passengersBoarded: 0,
|
||||
passengersDetrained: 0,
|
||||
cardsPlayed: 0,
|
||||
collisions: 0,
|
||||
revenueGained: 0,
|
||||
revenueLost: 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A deep copy, for freezing the official result (`FinalReport`).
|
||||
*
|
||||
* Written out rather than reached for via `structuredClone` because a Tally is a flat bag of numbers
|
||||
* with two containers in it, and spelling the copy out means a field added later that needs deep
|
||||
* copying is a compile error here rather than a shared reference discovered in a results screen.
|
||||
*/
|
||||
export function cloneTally(t: Tally): Tally {
|
||||
return {
|
||||
...t,
|
||||
circusStops: t.circusStops.map((c) => ({ ...c })),
|
||||
switchedSince: [...t.switchedSince],
|
||||
byPlayer: t.byPlayer.map((p) => ({ ...p })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* WHICH WAY TO DRAW THE ENGINE — east or west, for every train, everywhere.
|
||||
*
|
||||
@@ -866,8 +1059,34 @@ export type GameState = {
|
||||
collisionsToday: number;
|
||||
/** §3.4 — never reset; checked against `config.maxCollisionsTotal`. */
|
||||
collisionsTotal: number;
|
||||
status: 'setup' | 'active' | 'finished';
|
||||
/**
|
||||
* `awaitingExtension` is Gitea#11: the timetable has run out, the result is recorded, and the
|
||||
* table is being asked whether to play one more Day. It is a PAUSE, not an ending — `advance`
|
||||
* reports `needsInput` there, the server resumes it like any live game, and the only intent the
|
||||
* rules will accept is `game.extend`.
|
||||
*/
|
||||
status: 'setup' | 'active' | 'awaitingExtension' | 'finished';
|
||||
/** The CURRENT evaluation, re-decided at the end of every Day including extended ones. */
|
||||
outcome: Outcome | null;
|
||||
/**
|
||||
* §3.3 (Gitea#11) — Days granted beyond `config.days`, one vote at a time.
|
||||
*
|
||||
* `config.days` is deliberately never touched: it is what the official result was decided at, so
|
||||
* leaving it alone is what makes "the winner is decided at the original game length" a fact about
|
||||
* the code rather than a comment on it.
|
||||
*/
|
||||
extraDays: number;
|
||||
/**
|
||||
* Per PLAYER, while `awaitingExtension`. `null` means they have not voted yet.
|
||||
*
|
||||
* Unanimous, and one refusal is decisive: nobody is made to wait on a player who has already said
|
||||
* no (Jesse's call, 2026-08-28). Solitaire is the same code with one voter.
|
||||
*/
|
||||
extensionVotes: (boolean | null)[];
|
||||
/** Frozen at the FIRST ending and never overwritten. See `FinalReport`. */
|
||||
official: FinalReport | null;
|
||||
/** Gitea#16. Folded from the event stream; see `Tally`. */
|
||||
tally: Tally;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+67
-6
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. ──' };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
+44
-1
@@ -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;
|
||||
|
||||
+38
-5
@@ -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<Intent, { type: 'game.extend' }> {
|
||||
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' } };
|
||||
}
|
||||
|
||||
+123
-12
@@ -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
|
||||
? '<div class="vote-tally">' +
|
||||
f.players
|
||||
.map((p) => {
|
||||
const v = f.extensionVotes[p.index];
|
||||
const mark = v === true ? '✓' : v === false ? '✗' : '·';
|
||||
return `<span class="vote ${v === true ? 'yes' : v === false ? 'no' : 'wait'}">` +
|
||||
`${mark} ${esc(p.name)}${p.index === f.viewer ? ' (you)' : ''}</span>`;
|
||||
})
|
||||
.join('') +
|
||||
'</div>'
|
||||
: '';
|
||||
|
||||
el.innerHTML =
|
||||
`<div class="over ${won ? 'win' : 'loss'}">` +
|
||||
`${won ? 'THE DIVISION RAN' : 'THE DIVISION FAILED'} — Day ${f.official?.day ?? f.days} is scored.<br>` +
|
||||
'The result above is final. Play one more Day?</div>' +
|
||||
tally +
|
||||
(mine === null
|
||||
? '<button id="extend-yes">play one more Day</button>' +
|
||||
'<button id="extend-no">end the game here</button>'
|
||||
: `<div class="dim">You voted ${mine ? 'to play on' : 'to end it'}. ` +
|
||||
(waiting.length
|
||||
? `Waiting on ${waiting.map((p) => esc(p.name)).join(', ')}.`
|
||||
: 'Settling…') +
|
||||
'</div>') +
|
||||
'<button id="results">see the full results</button>';
|
||||
|
||||
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 =
|
||||
`<div class="over ${won ? 'win' : 'loss'}">` +
|
||||
`${won ? 'THE DIVISION RAN' : 'THE DIVISION FAILED'}<br>` +
|
||||
`final Revenue ${f.revenue}${f.objective.target > 0 ? ` against a target of ${f.objective.target}` : ''}</div>` +
|
||||
'<button id="results">see the full results</button>' +
|
||||
(session.capabilities.newGame ? '<button id="again">new game</button>' : '');
|
||||
|
||||
$('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 =
|
||||
`<div class="over ${o?.result === 'win' ? 'win' : 'loss'}">` +
|
||||
`${o?.result === 'win' ? 'YOU WIN' : 'GAME OVER'} — ${esc(String(o?.reason ?? ''))}<br>` +
|
||||
`final Revenue ${f.revenue} against a target of ${f.objective.target}</div>` +
|
||||
`<button id="again">new game</button>`;
|
||||
$('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 = '<div class="dim">nothing to decide — the engine is running the Division</div>';
|
||||
|
||||
+321
-38
@@ -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) =>
|
||||
`<tr${p.index === f.viewer ? ' class="you"' : ''}><td>${esc(p.name)}` +
|
||||
`${p.index === f.viewer ? ' <span class="dim">(you)</span>' : ''}</td>` +
|
||||
`<td class="num">${p.revenue}</td></tr>`,
|
||||
)
|
||||
.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
|
||||
? `<p>Combined Revenue <b>${combined}</b> against a target of <b>${f.minCombinedRevenue}</b>.</p>`
|
||||
: '';
|
||||
|
||||
/**
|
||||
* 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
|
||||
? `<p>Collisions: <b>${f.collisionsToday}</b> today, <b>${f.collisionsTotal}</b> in all.</p>`
|
||||
: '';
|
||||
const left = f.days + f.extraDays - ended;
|
||||
|
||||
const ahead =
|
||||
left <= 0
|
||||
? '<p>That was the last Day on the timetable.</p>'
|
||||
: `<p><b>Day ${f.day} of ${f.days}</b> begins now — ${left} ${left === 1 ? 'Day' : 'Days'} left to run.</p>`;
|
||||
: `<p><b>Day ${f.day} of ${f.days + f.extraDays}</b> begins now — ${left} ${left === 1 ? 'Day' : 'Days'} left to run.</p>`;
|
||||
|
||||
return (
|
||||
`<h3 class="dayend-h">Day ${ended} has ended</h3>` +
|
||||
ahead +
|
||||
`<table class="dayend-t"><tbody>${standings}</tbody></table>` +
|
||||
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 ? ' <span class="dim">(you)</span>' : '') +
|
||||
(p.index === winner ? ' <span class="wins">— winner</span>' : '');
|
||||
return (
|
||||
`<tr${p.index === f.viewer ? ' class="you"' : ''}><td>${esc(p.name)}${marks}</td>` +
|
||||
`<td class="num">${p.revenue}</td></tr>`
|
||||
);
|
||||
})
|
||||
.join('');
|
||||
return `<table class="dayend-t"><tbody>${rows}</tbody></table>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
`<p>Combined Revenue <b>${combined}</b> against a target of <b>${f.minCombinedRevenue}</b>` +
|
||||
`${met ? ' — cleared.' : ' — short.'}</p>`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
? `<p>Collisions: <b>${f.collisionsToday}</b> today, <b>${f.collisionsTotal}</b> in all.</p>`
|
||||
: '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Frame['outcome']>, 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: <b>${combined}</b> Revenue between everyone, against a floor of ` +
|
||||
`<b>${f.minCombinedRevenue}</b>. §3.3 — miss the floor and the whole table loses, whoever ` +
|
||||
`earned the most.`
|
||||
);
|
||||
case 'collisionFloor':
|
||||
return (
|
||||
`Too many collisions — <b>${f.collisionsToday}</b> in one Day and <b>${f.collisionsTotal}</b> ` +
|
||||
`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 `<h4 class="res-h">The rules in play</h4>${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 (
|
||||
'<table class="res-t"><tbody>' +
|
||||
rows.map(([k, v]) => `<tr><td>${esc(k)}</td><td>${esc(v)}</td></tr>`).join('') +
|
||||
'</tbody></table>'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 `<h4 class="res-h">The railroad</h4>${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 =
|
||||
'<tr><th></th><th class="num">Rev</th><th class="num">Loads</th><th class="num">Unloads</th>' +
|
||||
'<th class="num">Pass.</th><th class="num">Cards</th><th class="num">Crashes</th></tr>';
|
||||
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 (
|
||||
`<tr${p.index === f.viewer ? ' class="you"' : ''}><td>${esc(p.name)}</td>` +
|
||||
`<td class="num">${p.revenue}</td><td class="num">${q.loads}</td>` +
|
||||
`<td class="num">${q.unloads}</td>` +
|
||||
`<td class="num">${q.passengersBoarded + q.passengersDetrained}</td>` +
|
||||
`<td class="num">${q.cardsPlayed}</td><td class="num">${q.collisions}</td></tr>`
|
||||
);
|
||||
})
|
||||
.join('');
|
||||
return `<h4 class="res-h">Who did what</h4><table class="res-t res-wide"><tbody>${head}${rows}</tbody></table>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 '<p class="dim">This game has not ended.</p>';
|
||||
|
||||
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'
|
||||
? '<p>Nobody wins this one.</p>'
|
||||
: o.winner === null
|
||||
? '<p>The table clears it together — a Co-op game has no individual winner.</p>'
|
||||
: `<p><b>${esc(winnerName ?? '')}</b> finishes ahead on Revenue.</p>`;
|
||||
|
||||
const extended =
|
||||
f.extraDays > 0
|
||||
? '<h4 class="res-h">After the timetable</h4>' +
|
||||
`<p>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.</p>' +
|
||||
standingsHtml(f) +
|
||||
targetHtml(f) +
|
||||
collisionsHtml(f) +
|
||||
tallyHtml(f.tally)
|
||||
: '';
|
||||
|
||||
return (
|
||||
`<h3 class="dayend-h res-${o.result}">${headline}</h3>` +
|
||||
`<p>${reasonSentence(frozen, o, officialDay)}</p>` +
|
||||
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}
|
||||
`;
|
||||
|
||||
@@ -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}
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- THE END-OF-GAME RESULTS (Gitea#16). Filled by `resultsHtml` and opened from `renderEnding`,
|
||||
which puts it up once per ending unasked and leaves a button to reopen it. Reopenable matters:
|
||||
Gitea#11 lets a table play past the end, and continuing must not cost you the results screen. -->
|
||||
<dialog id="resultsdlg" aria-labelledby="rs-title">
|
||||
<form method="dialog">
|
||||
<div id="resultsbody"></div>
|
||||
<menu class="ng-buttons">
|
||||
<button value="ok" id="rs-ok" type="submit">Close</button>
|
||||
</menu>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- THE HANDOFF, between `Lobby.Start` and the first Frame.
|
||||
`beginRemote` used to write "… connecting to the game" into `#presence` — the DISCONNECT banner,
|
||||
whose job is `⚠ waiting on Alice`. It worked only because the first render overwrote it, and it
|
||||
|
||||
+4
-1
@@ -27,6 +27,7 @@ import {
|
||||
currentActor,
|
||||
fromSave,
|
||||
handPlayable,
|
||||
isOutOfTurn,
|
||||
newGame,
|
||||
overHandLimit,
|
||||
submit,
|
||||
@@ -163,7 +164,9 @@ export function createLocalSession(seed: number, options?: NewGameOptions): Loca
|
||||
overHandLimit: () => overHandLimit(game),
|
||||
handPlayable: () => handPlayable(game),
|
||||
submit: async (intent: Intent) => {
|
||||
const ok = submit(game, intent);
|
||||
// Seat 0 is the solitaire player, and the extension vote (Gitea#11) is the one intent that
|
||||
// arrives when `currentActor` is null — so it has to name its seat. See `isOutOfTurn`.
|
||||
const ok = submit(game, intent, isOutOfTurn(intent) ? 0 : null);
|
||||
if (ok) changed();
|
||||
return ok;
|
||||
},
|
||||
|
||||
+24
-5
@@ -66,6 +66,21 @@ function playToCompletion(s: GameState, seed = 1, maxTurns = 20_000): PlayStats
|
||||
tally(pump(s));
|
||||
if (s.status === 'finished') break;
|
||||
|
||||
/**
|
||||
* §3.3, EXTENDED PLAY (Gitea#11) — this harness plays the timetable it was dealt.
|
||||
*
|
||||
* It picks at random among legal options, and one of the two options here is "play another
|
||||
* Day" — so left alone it would extend the game forever and terminate only on `maxTurns`. That
|
||||
* is not a bug in the feature: a game genuinely does not end now until somebody says stop, and
|
||||
* `developerBot` says stop for exactly this reason. Said explicitly here rather than folded
|
||||
* into the random pick, because "how does this loop terminate" deserves an answer in the loop.
|
||||
*/
|
||||
if (s.status === 'awaitingExtension') {
|
||||
const r = applyIntent(s, 0, { type: 'game.extend', player: 0, agree: false });
|
||||
assert.ok(r.ok, 'a solitaire player could not decline an extension');
|
||||
break;
|
||||
}
|
||||
|
||||
const actor = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
|
||||
if (actor === null) break;
|
||||
|
||||
@@ -544,9 +559,13 @@ describe('victory conditions (§3, Gap 10e) — unified 2026-08-20', () => {
|
||||
s.clock.phase = 'shiftChange';
|
||||
s.players[0]!.revenue = 0;
|
||||
advance(s);
|
||||
assert.equal(s.status, 'finished');
|
||||
// PAUSES rather than finishes since Gitea#11: a days-based ending offers another Day, and the
|
||||
// result is recorded either way. `official` is the frozen answer; `status` is only where play
|
||||
// has got to. The two collision tests at the foot of this block are the contrast.
|
||||
assert.equal(s.status, 'awaitingExtension');
|
||||
assert.equal(s.outcome!.result, 'loss');
|
||||
assert.equal(s.outcome!.reason, 'revenueFloor');
|
||||
assert.equal(s.official!.outcome.reason, 'revenueFloor');
|
||||
});
|
||||
|
||||
it('wins a timed Solitaire game that clears minCombinedRevenue', () => {
|
||||
@@ -556,7 +575,7 @@ describe('victory conditions (§3, Gap 10e) — unified 2026-08-20', () => {
|
||||
s.clock.phase = 'shiftChange';
|
||||
s.players[0]!.revenue = 10;
|
||||
advance(s);
|
||||
assert.equal(s.status, 'finished');
|
||||
assert.equal(s.status, 'awaitingExtension');
|
||||
assert.equal(s.outcome!.result, 'win');
|
||||
});
|
||||
|
||||
@@ -567,7 +586,7 @@ describe('victory conditions (§3, Gap 10e) — unified 2026-08-20', () => {
|
||||
s.clock.phase = 'shiftChange';
|
||||
s.players[0]!.revenue = 0;
|
||||
advance(s);
|
||||
assert.equal(s.status, 'finished');
|
||||
assert.equal(s.status, 'awaitingExtension');
|
||||
assert.equal(s.outcome!.result, 'win');
|
||||
});
|
||||
|
||||
@@ -582,7 +601,7 @@ describe('victory conditions (§3, Gap 10e) — unified 2026-08-20', () => {
|
||||
s.players[0]!.revenue = 5; // best individual score...
|
||||
s.players[1]!.revenue = 3; // ...but combined (8) still misses the floor (20).
|
||||
advance(s);
|
||||
assert.equal(s.status, 'finished');
|
||||
assert.equal(s.status, 'awaitingExtension');
|
||||
assert.equal(s.outcome!.result, 'loss');
|
||||
assert.equal(s.outcome!.reason, 'revenueFloor');
|
||||
});
|
||||
@@ -598,7 +617,7 @@ describe('victory conditions (§3, Gap 10e) — unified 2026-08-20', () => {
|
||||
s.players[0]!.revenue = 4;
|
||||
s.players[1]!.revenue = 6; // combined 10 clears the floor, neither alone would.
|
||||
advance(s);
|
||||
assert.equal(s.status, 'finished');
|
||||
assert.equal(s.status, 'awaitingExtension');
|
||||
assert.equal(s.outcome!.result, 'win');
|
||||
assert.equal(s.outcome!.winner, null, 'Co-op names an individual winner instead of a shared one');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* §3.3, EXTENDED PLAY — Gitea#11, "when game ends allow players to continue playing if they wish".
|
||||
*
|
||||
* The rule as Jesse specified it (2026-08-28), which is what these tests are written against:
|
||||
*
|
||||
* - the OFFICIAL result is decided at the original game length and never changes. "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";
|
||||
* - extending grants exactly ONE Day, and the question is put again at the end of it;
|
||||
* - solitaire: the player decides alone. Multiplayer: unanimous, and one refusal ends it there;
|
||||
* - only days-based endings offer it. A §3.4 collision breach is final, during an extended Day
|
||||
* just as during the regular game.
|
||||
*
|
||||
* The persistence half matters as much as the rules half: a save is `{ seed, config, history }`
|
||||
* replayed through the engine, so an extension that is not an INTENT does not survive a reload, an
|
||||
* Undo, or a server restart. `replays the extension` below is the test that pins that.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { advance, pump } from '../src/engine/advance.ts';
|
||||
import { applyIntent, check } from '../src/engine/apply.ts';
|
||||
import { STAGES_PER_DAY } from '../src/engine/content.ts';
|
||||
import { legalActions } from '../src/engine/legal.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import { developerBot, playGame, randomBot } from '../src/sim/bot.ts';
|
||||
import type { GameConfig, GameState } from '../src/engine/state.ts';
|
||||
|
||||
const baseConfig = (over: Partial<GameConfig> = {}): GameConfig => ({
|
||||
mode: 'solitaire',
|
||||
days: 3,
|
||||
minCombinedRevenue: 0,
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
...over,
|
||||
});
|
||||
|
||||
const game = (over: Partial<GameConfig> = {}, names = ['Jesse']): GameState =>
|
||||
createGame({ id: 'g', seed: 1, config: baseConfig(over), playerNames: names });
|
||||
|
||||
/** Parks a game on the last Stage of its final Day, so one `advance` runs the clock off the end. */
|
||||
function atTheEnd(s: GameState): GameState {
|
||||
s.clock.day = s.config.days + s.extraDays + 1;
|
||||
s.clock.stage = STAGES_PER_DAY;
|
||||
s.clock.phase = 'shiftChange';
|
||||
return s;
|
||||
}
|
||||
|
||||
describe('§3.3 extended play — the ending pauses rather than stopping (Gitea#11)', () => {
|
||||
it('offers another Day on a days-based ending, and records the result anyway', () => {
|
||||
const s = atTheEnd(game());
|
||||
advance(s);
|
||||
assert.equal(s.status, 'awaitingExtension', 'a days-based ending did not offer another Day');
|
||||
assert.ok(s.outcome, 'the result was not decided');
|
||||
assert.ok(s.official, 'the official result was not frozen');
|
||||
assert.equal(s.official!.day, s.config.days, 'the official Day is not the original game length');
|
||||
});
|
||||
|
||||
it('does NOT offer another Day after a collision breach — §3.4 is final', () => {
|
||||
const s = game({ mode: 'competitive', maxCollisionsPerDay: 2 });
|
||||
s.collisionsToday = 2;
|
||||
s.clock.stage = 1;
|
||||
s.clock.phase = 'shiftChange';
|
||||
advance(s);
|
||||
assert.equal(s.status, 'finished', 'a railroad declared unsafe offered to carry on');
|
||||
assert.equal(s.outcome!.reason, 'collisionFloor');
|
||||
});
|
||||
|
||||
it('refuses every ordinary intent while the extension question is open', () => {
|
||||
const s = atTheEnd(game());
|
||||
advance(s);
|
||||
assert.equal(check(s, 0, { type: 'draw.fromHomeOffice' }), 'WRONG_PHASE');
|
||||
assert.equal(check(s, 0, { type: 'game.extend', player: 0, agree: true }), null, 'the vote itself was refused');
|
||||
});
|
||||
|
||||
it('offers only the two votes, and only to a seat that has not voted', () => {
|
||||
const s = atTheEnd(game({ mode: 'competitive' }, ['A', 'B']));
|
||||
advance(s);
|
||||
assert.deepEqual(
|
||||
legalActions(s, 0).map((i) => i.type),
|
||||
['game.extend', 'game.extend'],
|
||||
'something other than the vote is legal while the game is stopped',
|
||||
);
|
||||
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
|
||||
assert.equal(legalActions(s, 0).length, 0, 'a seat was offered a second vote');
|
||||
assert.equal(check(s, 0, { type: 'game.extend', player: 0, agree: true }), 'ALREADY_VOTED');
|
||||
assert.equal(legalActions(s, 1).length, 2, 'the seat still to vote was not offered the vote');
|
||||
});
|
||||
|
||||
it('refuses the vote when no extension is pending', () => {
|
||||
const s = game();
|
||||
assert.equal(check(s, 0, { type: 'game.extend', player: 0, agree: true }), 'NOT_AWAITING_EXTENSION');
|
||||
});
|
||||
});
|
||||
|
||||
describe('§3.3 extended play — one Day at a time (Gitea#11)', () => {
|
||||
it('grants exactly one Day in solitaire, then asks again at the end of it', () => {
|
||||
const s = atTheEnd(game());
|
||||
advance(s);
|
||||
const official = { ...s.official!.outcome };
|
||||
|
||||
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
|
||||
assert.equal(s.status, 'active', 'agreeing did not resume play');
|
||||
assert.equal(s.extraDays, 1, 'more or less than one Day was granted');
|
||||
assert.deepEqual(s.extensionVotes, [null], 'the votes were not cleared for the next question');
|
||||
|
||||
// Run the extra Day off the end: the question comes round again.
|
||||
atTheEnd(s);
|
||||
advance(s);
|
||||
assert.equal(s.status, 'awaitingExtension', 'the second ending did not ask again');
|
||||
assert.equal(s.extraDays, 1, 'a second Day was granted without being asked for');
|
||||
assert.deepEqual(s.official!.outcome, official, 'the official result was rewritten');
|
||||
assert.equal(s.official!.day, s.config.days, 'the official Day moved with the extension');
|
||||
});
|
||||
|
||||
it('ends the moment one seat declines, without waiting for the rest', () => {
|
||||
const s = atTheEnd(game({ mode: 'competitive' }, ['A', 'B', 'C']));
|
||||
advance(s);
|
||||
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
|
||||
assert.equal(s.status, 'awaitingExtension', 'one yes ended the vote');
|
||||
|
||||
applyIntent(s, 1, { type: 'game.extend', player: 1, agree: false });
|
||||
assert.equal(s.status, 'finished', 'a refusal did not end the game immediately');
|
||||
assert.equal(s.extraDays, 0, 'a Day was granted despite a refusal');
|
||||
assert.equal(check(s, 2, { type: 'game.extend', player: 2, agree: true }), 'NOT_AWAITING_EXTENSION',
|
||||
'the seat that never voted is still being waited on');
|
||||
});
|
||||
|
||||
it('needs every seat before it grants the Day', () => {
|
||||
const s = atTheEnd(game({ mode: 'competitive' }, ['A', 'B', 'C']));
|
||||
advance(s);
|
||||
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
|
||||
applyIntent(s, 1, { type: 'game.extend', player: 1, agree: true });
|
||||
assert.equal(s.status, 'awaitingExtension', 'two of three votes granted the Day');
|
||||
applyIntent(s, 2, { type: 'game.extend', player: 2, agree: true });
|
||||
assert.equal(s.status, 'active', 'a unanimous table was not given its Day');
|
||||
assert.equal(s.extraDays, 1);
|
||||
});
|
||||
|
||||
it('keeps the official result when a collision ends an EXTENDED Day', () => {
|
||||
// The case the freeze exists for: a railroad declared unsafe on the extra Day does not retract
|
||||
// who won on the last scheduled one.
|
||||
const s = atTheEnd(game({ mode: 'competitive', maxCollisionsPerDay: 2 }, ['A', 'B']));
|
||||
s.players[0]!.revenue = 9;
|
||||
s.players[1]!.revenue = 2;
|
||||
advance(s);
|
||||
const official = { ...s.official!.outcome };
|
||||
assert.equal(official.result, 'win');
|
||||
assert.equal(official.winner, 0);
|
||||
|
||||
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
|
||||
applyIntent(s, 1, { type: 'game.extend', player: 1, agree: true });
|
||||
assert.equal(s.status, 'active');
|
||||
|
||||
s.collisionsToday = 2;
|
||||
s.clock.stage = 1;
|
||||
s.clock.phase = 'shiftChange';
|
||||
advance(s);
|
||||
assert.equal(s.status, 'finished');
|
||||
assert.equal(s.outcome!.reason, 'collisionFloor', 'the current evaluation was not updated');
|
||||
assert.deepEqual(s.official!.outcome, official, 'a late collision rewrote a recorded win');
|
||||
});
|
||||
|
||||
it('decides the winner at the ORIGINAL game length, whoever leads afterwards', () => {
|
||||
const s = atTheEnd(game({ mode: 'competitive' }, ['A', 'B']));
|
||||
s.players[0]!.revenue = 9;
|
||||
s.players[1]!.revenue = 2;
|
||||
advance(s);
|
||||
assert.equal(s.official!.outcome.winner, 0);
|
||||
assert.deepEqual(s.official!.revenues, [9, 2], 'the official standings were not frozen');
|
||||
|
||||
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
|
||||
applyIntent(s, 1, { type: 'game.extend', player: 1, agree: true });
|
||||
|
||||
// B overtakes A during the extra Day, and it changes nothing official.
|
||||
s.players[1]!.revenue = 40;
|
||||
atTheEnd(s);
|
||||
advance(s);
|
||||
assert.equal(s.official!.outcome.winner, 0, 'overtaking after the timetable took the win');
|
||||
assert.deepEqual(s.official!.revenues, [9, 2], 'the frozen standings moved');
|
||||
assert.equal(s.outcome!.winner, 1, 'the informational evaluation did not follow the new leader');
|
||||
});
|
||||
});
|
||||
|
||||
describe('§3.3 extended play — it survives being replayed (Gitea#11)', () => {
|
||||
it('reproduces an extended game from seed and intents alone', () => {
|
||||
// The reason the vote is an intent at all. A save is a replay, so a decision that is not in the
|
||||
// history did not happen — an extended game would evaporate on the next reload.
|
||||
const play = (): GameState => {
|
||||
const s = atTheEnd(game());
|
||||
advance(s);
|
||||
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
|
||||
return s;
|
||||
};
|
||||
const a = play();
|
||||
const b = play();
|
||||
assert.equal(a.extraDays, b.extraDays);
|
||||
assert.equal(a.status, b.status);
|
||||
assert.deepEqual(a.official!.outcome, b.official!.outcome);
|
||||
});
|
||||
|
||||
it('narrates the vote, so a table can see who called time', () => {
|
||||
const s = atTheEnd(game({ mode: 'competitive' }, ['A', 'B']));
|
||||
advance(s);
|
||||
const yes = applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
|
||||
assert.ok(yes.ok);
|
||||
assert.deepEqual(yes.events.map((e) => e.type), ['extensionVoted']);
|
||||
const no = applyIntent(s, 1, { type: 'game.extend', player: 1, agree: false });
|
||||
assert.ok(no.ok);
|
||||
assert.deepEqual(no.events.map((e) => e.type), ['extensionVoted', 'playConcluded']);
|
||||
});
|
||||
|
||||
it('announces the granted Day', () => {
|
||||
const s = atTheEnd(game());
|
||||
advance(s);
|
||||
const r = applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
|
||||
assert.ok(r.ok);
|
||||
assert.deepEqual(r.events.map((e) => e.type), ['extensionVoted', 'dayExtended']);
|
||||
const extended = r.events.find((e) => e.type === 'dayExtended');
|
||||
assert.equal(extended && 'day' in extended ? extended.day : null, s.config.days + 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('§3.3 extended play — bots play the timetable they were dealt (Gitea#11)', () => {
|
||||
it('REGRESSION: a simulated game terminates whatever the policy would vote', () => {
|
||||
/**
|
||||
* `playGame` declines on the driver's own account rather than leaving it to the policy, and this
|
||||
* is why. `randomBot` picks uniformly among its legal options, so it takes another Day about
|
||||
* half the time — and since a table may go on granting Days for ever, the game then runs to
|
||||
* `maxTurns`. It did: `test/sim.test.ts` went from under a second to an unbounded hang, every
|
||||
* seeded game in the harness playing fifty thousand turns instead of a couple of hundred.
|
||||
*
|
||||
* `randomBot` is the policy that exposes it, but the guarantee has to hold for any policy,
|
||||
* including ones not written yet — hence the fix living in the driver and the test living here.
|
||||
*/
|
||||
const s = createGame({ id: 'g', seed: 9, config: baseConfig({ days: 1 }), playerNames: ['Jesse'] });
|
||||
const out = playGame(s, randomBot(7), pump, 4_000);
|
||||
assert.equal(s.status, 'finished', 'a simulated game did not finish');
|
||||
assert.equal(s.extraDays, 0, 'the harness played Days the game was not dealt');
|
||||
assert.ok(out.turns < 4_000, `ran to the turn cap (${out.turns}) instead of ending`);
|
||||
});
|
||||
|
||||
it('developerBot declines, so a bot-only game ends on schedule', () => {
|
||||
// "If only bots are playing, they never vote to extend" (Jesse, 2026-08-28). The server votes
|
||||
// yes on a bot's behalf once every human has already agreed; this policy is what is left when
|
||||
// there are no humans to follow.
|
||||
const s = atTheEnd(game());
|
||||
advance(s);
|
||||
const choice = developerBot.choose(s, 0, legalActions(s, 0));
|
||||
assert.equal(choice.type, 'game.extend');
|
||||
assert.equal('agree' in choice ? choice.agree : null, false, 'a bot asked for another Day');
|
||||
});
|
||||
});
|
||||
+12
-1
@@ -304,7 +304,18 @@ describe('every published replay actually replays', () => {
|
||||
`${f} is dead — it replays ${back.history.length} of ${save.history.length} intents. ` +
|
||||
'Re-record it with save-replay.ts rather than editing the file.',
|
||||
);
|
||||
assert.equal(back.state.status, 'finished', `${f} does not reach the end of its game`);
|
||||
/**
|
||||
* `awaitingExtension` counts as the end since Gitea#11 — and for a published file it is the
|
||||
* EXPECTED end. These saves were recorded before extended play existed, so their histories
|
||||
* carry no `game.extend` vote: replaying one runs the timetable out and stops on the question
|
||||
* nobody was there to answer. That is a game that reached the end of its own history, which is
|
||||
* what this test is about. `active` would still be a dead replay.
|
||||
*/
|
||||
assert.ok(
|
||||
back.state.status === 'finished' || back.state.status === 'awaitingExtension',
|
||||
`${f} does not reach the end of its game — status ${back.state.status}`,
|
||||
);
|
||||
assert.ok(back.state.official !== null, `${f} ends without recording a result`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -99,6 +99,30 @@ describe('redaction — a seat\'s Frame never carries another seat\'s secrets',
|
||||
assert.ok(!serialized.includes(String(s.seed)), 'the seed value leaked into the Frame some other way');
|
||||
});
|
||||
|
||||
it('the tally that rides the Frame is aggregate counts, never a card id (Gitea#16)', () => {
|
||||
// Gitea#16's statistics live on `GameState` and reach a remote client on the Frame, which is
|
||||
// only safe because nothing in a Tally identifies a card. That is a property of what
|
||||
// `tally.ts` chooses to count, and nothing in the type system enforces it — so it is asserted
|
||||
// here, where a future counter that stashed a `cardId` "just for badges" would be caught.
|
||||
for (const players of [3, 4]) {
|
||||
const s = midGame(players, 5000 + players);
|
||||
const secrets = new Set<string>([...s.decks.homeOffice]);
|
||||
for (const hand of s.decks.hands.values()) for (const id of hand) secrets.add(id);
|
||||
for (let viewer = 0 as PlayerIndex; viewer < players; viewer++) {
|
||||
const serialized = JSON.stringify(snapshot(s, [], null, null, null, false, viewer).tally);
|
||||
for (const cardId of secrets) {
|
||||
assert.ok(!serialized.includes(`"${cardId}"`), `the tally carries card id "${cardId}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('every seat sees the SAME tally — it is the table\'s account, not a private one', () => {
|
||||
const s = midGame(3, 5555);
|
||||
const tallies = [0, 1, 2].map((p) => snapshot(s, [], null, null, null, false, p as PlayerIndex).tally);
|
||||
for (const t of tallies) assert.deepEqual(t, tallies[0], 'the tally differs by seat');
|
||||
});
|
||||
|
||||
it('only the viewer\'s own hand and handCount are non-public — everything else matches across seats', () => {
|
||||
// The redaction surface is four fields (§7), not sixty event types. Cross-check that seats agree
|
||||
// on everything else a Frame carries about shared state.
|
||||
|
||||
@@ -412,3 +412,122 @@ describe('the four transient signals (2026-08-23)', () => {
|
||||
assert.equal(later.scheduled, undefined, 'a reconnecting client was re-sent an old timetable flash');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('§3.3 extended play across the server (Gitea#11)', () => {
|
||||
/**
|
||||
* A one-Day game, so these tests reach the end of the timetable by actually PLAYING to it.
|
||||
*
|
||||
* There is no back door into a session's engine state and there should not be — `connect`,
|
||||
* `intent`, `exportSave` and `summary` are the whole surface. So the clock is run down through the
|
||||
* same calls a client makes, which has the side benefit of exercising the real path: what is under
|
||||
* test here is the session's handling of the vote (the turn guard, the bots, the saved status),
|
||||
* and reaching it any other way would prove less.
|
||||
*/
|
||||
const oneDay: GameConfig = { ...config, days: 1 };
|
||||
|
||||
/** What seat `seat` can currently see. `connect` always yields a full Frame, never a delta. */
|
||||
const frameOf = (session: GameSession, seat: PlayerIndex) => session.connect(seat).frame!;
|
||||
|
||||
/** Plays until the game stops asking for ordinary moves. Returns the Frame it stopped on. */
|
||||
function playToTheEnd(session: GameSession, seats: PlayerIndex[]) {
|
||||
let seq = 0;
|
||||
for (let i = 0; i < 5_000; i++) {
|
||||
const acting = seats.find((s) => session.connect(s).menu !== null);
|
||||
if (acting === undefined) break;
|
||||
const menu = session.connect(acting).menu!;
|
||||
if (menu.options.length === 0) break;
|
||||
if (!session.intent(acting, seq++, menu.options[0]!).accepted) break;
|
||||
}
|
||||
return { frame: frameOf(session, seats[0]!), seq };
|
||||
}
|
||||
|
||||
it('stops to ask rather than ending, and every seat can see the question', () => {
|
||||
const session = createSession(42, oneDay, ['Alice', 'Bob']);
|
||||
const { frame } = playToTheEnd(session, [0, 1] as PlayerIndex[]);
|
||||
assert.equal(frame.status, 'awaitingExtension', 'the game did not stop to ask');
|
||||
assert.ok(frame.official, 'the official result did not reach the client');
|
||||
assert.deepEqual(frame.extensionVotes, [null, null], 'the votes did not reach the client');
|
||||
});
|
||||
|
||||
it('accepts the vote from a seat that is not the current actor', () => {
|
||||
// `currentActor` is null once the game has stopped, so the ordinary turn guard would refuse
|
||||
// every vote with NOT_YOUR_TURN. Both seats vote here and neither of them is the actor.
|
||||
const session = createSession(42, oneDay, ['Alice', 'Bob']);
|
||||
const { seq } = playToTheEnd(session, [0, 1] as PlayerIndex[]);
|
||||
const a = session.intent(0 as PlayerIndex, seq + 1, { type: 'game.extend', player: 0, agree: true });
|
||||
assert.equal(a.accepted, true, 'seat 0 could not vote');
|
||||
const b = session.intent(1 as PlayerIndex, seq + 2, { type: 'game.extend', player: 1, agree: true });
|
||||
assert.equal(b.accepted, true, 'seat 1 could not vote');
|
||||
|
||||
const after = frameOf(session, 0 as PlayerIndex);
|
||||
assert.equal(after.extraDays, 1, 'a unanimous table was not given its Day');
|
||||
assert.equal(after.status, 'active', 'play did not resume');
|
||||
});
|
||||
|
||||
it('bots agree only once every human has, and never lead', () => {
|
||||
// "Bots will not disagree with the human. Humans get to vote first" (Jesse, 2026-08-28).
|
||||
const session = createSession(42, oneDay, ['Alice', 'Botty'], [1 as PlayerIndex]);
|
||||
const { seq } = playToTheEnd(session, [0, 1] as PlayerIndex[]);
|
||||
assert.equal(frameOf(session, 0 as PlayerIndex).status, 'awaitingExtension');
|
||||
assert.equal(
|
||||
frameOf(session, 0 as PlayerIndex).extensionVotes[1],
|
||||
null,
|
||||
'the bot voted before the human did',
|
||||
);
|
||||
|
||||
session.intent(0 as PlayerIndex, seq + 1, { type: 'game.extend', player: 0, agree: true });
|
||||
const after = frameOf(session, 0 as PlayerIndex);
|
||||
assert.equal(after.extraDays, 1, 'the bot did not follow the human into another Day');
|
||||
assert.equal(after.status, 'active');
|
||||
});
|
||||
|
||||
it('a human refusal ends it, and no bot overrides that', () => {
|
||||
const session = createSession(42, oneDay, ['Alice', 'Botty'], [1 as PlayerIndex]);
|
||||
const { seq } = playToTheEnd(session, [0, 1] as PlayerIndex[]);
|
||||
session.intent(0 as PlayerIndex, seq + 1, { type: 'game.extend', player: 0, agree: false });
|
||||
const after = frameOf(session, 0 as PlayerIndex);
|
||||
assert.equal(after.status, 'finished');
|
||||
assert.equal(after.extraDays, 0);
|
||||
});
|
||||
|
||||
it('REGRESSION: an all-bot game ends rather than hanging on the question', () => {
|
||||
/**
|
||||
* `driveBots` loops on `currentActor`, which is null the moment the game stops to ask — so it
|
||||
* cannot cast the vote itself, and the bots' vote is driven separately. The first cut of that
|
||||
* driver returned early when there were no humans to follow, on the reasoning that a bot-only
|
||||
* table would decline through the ordinary path. It has no ordinary path: nothing ever asked
|
||||
* the bots, and an all-bot session sat on the question for ever without reaching `finished`.
|
||||
* Caught by `summary()`'s own "a finished game waits on nobody" test.
|
||||
*/
|
||||
const session = createSession(4242, oneDay, ['A', 'B'], [0, 1] as PlayerIndex[]);
|
||||
assert.equal(frameOf(session, 0 as PlayerIndex).status, 'finished', 'the bots never answered');
|
||||
assert.equal(session.summary().waitingOn, null);
|
||||
assert.equal(frameOf(session, 0 as PlayerIndex).extraDays, 0, 'bots voted themselves another Day');
|
||||
});
|
||||
|
||||
it('saves a game awaiting its vote as ACTIVE, so a restart resumes it', () => {
|
||||
// `server/index.ts` never loads a `finished` game back into memory. A game paused on the
|
||||
// extension question is waiting on its table, not over — persisting it as finished would strand
|
||||
// it on disk mid-decision.
|
||||
const session = createSession(42, oneDay, ['Alice', 'Bob']);
|
||||
playToTheEnd(session, [0, 1] as PlayerIndex[]);
|
||||
assert.equal(frameOf(session, 0 as PlayerIndex).status, 'awaitingExtension');
|
||||
assert.equal(session.exportSave().status, 'active', 'a paused game was saved as finished');
|
||||
assert.equal(session.summary().status, 'active');
|
||||
});
|
||||
|
||||
it('resumes a paused game from its history, vote and all', () => {
|
||||
const session = createSession(42, oneDay, ['Alice', 'Bob']);
|
||||
const { seq } = playToTheEnd(session, [0, 1] as PlayerIndex[]);
|
||||
session.intent(0 as PlayerIndex, seq + 1, { type: 'game.extend', player: 0, agree: true });
|
||||
|
||||
const resumed = resumeSession(session.exportSave());
|
||||
const a = frameOf(session, 0 as PlayerIndex);
|
||||
const b = frameOf(resumed, 0 as PlayerIndex);
|
||||
assert.deepEqual(b.extensionVotes, a.extensionVotes, 'the votes did not survive the replay');
|
||||
assert.equal(b.extraDays, a.extraDays, 'the extra Day did not survive the replay');
|
||||
assert.deepEqual(b.official, a.official, 'the official result did not survive the replay');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,7 +63,13 @@ describe('a local session plays the same game as the calls it replaced', () => {
|
||||
assert.ok(turns > 50, `only ${turns} decisions — the game stalled`);
|
||||
|
||||
const f = session.view();
|
||||
assert.equal(f.status, 'finished');
|
||||
/**
|
||||
* `awaitingExtension` since Gitea#11, not `finished`: both loops stop when there is no actor,
|
||||
* and a days-based ending now parks the game on the "play one more Day?" question rather than
|
||||
* ending it outright. What this test is actually about is unchanged — the two sides reach the
|
||||
* SAME position — and the assertion below is still the one carrying that.
|
||||
*/
|
||||
assert.equal(f.status, 'awaitingExtension');
|
||||
assert.equal(f.status, view(game).status);
|
||||
assert.equal(f.day, view(game).day);
|
||||
assert.deepEqual(f.cells, view(game).cells, 'the board differs across the boundary');
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* THE EVENT TALLY — Gitea#16's statistics, and the one property they depend on.
|
||||
*
|
||||
* "I don't know if we keep statistics on…" is the question the issue opens with. Nothing was being
|
||||
* kept; `GameState.tally` now is, folded from the event stream at the two places every event passes
|
||||
* through (`engine/tally.ts` explains which and why).
|
||||
*
|
||||
* The property that matters is EXACTLY ONCE. A statistic folded twice reads high and a statistic
|
||||
* folded nowhere reads zero, and both are indistinguishable from a quiet game when you are looking
|
||||
* at a results screen. So the central test here does not assert particular numbers: it plays real
|
||||
* games, collects every event the engine emitted along the way, counts them independently, and
|
||||
* checks the tally against that count. A fold hooked in the wrong place fails it whatever the seed.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { advance } from '../src/engine/advance.ts';
|
||||
import { tallyEvent } from '../src/engine/tally.ts';
|
||||
import { applyIntent } from '../src/engine/apply.ts';
|
||||
import { STAGES_PER_DAY } from '../src/engine/content.ts';
|
||||
import { legalActions } from '../src/engine/legal.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import { developerBot } from '../src/sim/bot.ts';
|
||||
import type { GameEvent } from '../src/engine/events.ts';
|
||||
import type { GameConfig, GameState } from '../src/engine/state.ts';
|
||||
|
||||
const baseConfig = (over: Partial<GameConfig> = {}): GameConfig => ({
|
||||
mode: 'solitaire',
|
||||
days: 3,
|
||||
minCombinedRevenue: 0,
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
...over,
|
||||
});
|
||||
|
||||
/** Plays a whole game with the developer bot, keeping every event the engine produced. */
|
||||
function playKeepingEvents(
|
||||
seed: number,
|
||||
over: Partial<GameConfig> = {},
|
||||
names = ['Jesse'],
|
||||
): { state: GameState; events: GameEvent[] } {
|
||||
const state = createGame({ id: 'g', seed, config: baseConfig(over), playerNames: names });
|
||||
const events: GameEvent[] = [];
|
||||
for (let i = 0; i < 20_000; i++) {
|
||||
const r = advance(state);
|
||||
events.push(...r.events);
|
||||
if (state.status === 'finished') break;
|
||||
if (!r.needsInput) continue;
|
||||
|
||||
// The bot declines an extension, so this terminates on the timetable it was dealt (Gitea#11).
|
||||
const actor =
|
||||
state.status === 'awaitingExtension'
|
||||
? state.extensionVotes.findIndex((v) => v === null)
|
||||
: state.clock.pendingDecision !== null
|
||||
? state.clock.superintendent
|
||||
: state.clock.currentActor;
|
||||
if (actor === null || actor < 0) break;
|
||||
const options = legalActions(state, actor);
|
||||
if (options.length === 0) break;
|
||||
const applied = applyIntent(state, actor, developerBot.choose(state, actor, options));
|
||||
if (!applied.ok) break;
|
||||
events.push(...applied.events);
|
||||
}
|
||||
return { state, events };
|
||||
}
|
||||
|
||||
/** Counts events the way `tally.ts` should have, without sharing any of its code. */
|
||||
function countIndependently(events: GameEvent[]) {
|
||||
let sum = 0;
|
||||
const n = (type: GameEvent['type']): number => events.filter((e) => e.type === type).length;
|
||||
for (const e of events) {
|
||||
if (e.type === 'carsCoupled' || e.type === 'carsDropped') sum += e.stock.length;
|
||||
}
|
||||
return {
|
||||
trainsCompleted: n('trainCompleted'),
|
||||
loadsCompleted: n('loadCompleted'),
|
||||
unloadsCompleted: n('unloadCompleted'),
|
||||
loadsStarted: n('loadStarted'),
|
||||
unloadsBegun: n('unloadBegan'),
|
||||
passengersBoarded: n('passengersBoarded'),
|
||||
passengersDetrained: n('passengersDetrained'),
|
||||
cardsDrawn: n('cardDrawn'),
|
||||
cardsPlayed: n('cardPlayed'),
|
||||
cardsDiscarded: n('cardDiscarded'),
|
||||
officeUpgrades: n('officeUpgraded'),
|
||||
flyingSwitches: n('flyingSwitch'),
|
||||
extrasStarted: n('extraStarted'),
|
||||
secondSections: n('secondSectionOrdered'),
|
||||
trainsHeld: n('trainHeld'),
|
||||
trainsDiverted: n('trainDiverted'),
|
||||
expediteFaults: n('expediteFault'),
|
||||
facilitiesUnjammed: n('facilityUnjammed'),
|
||||
dispatchBonusesUsed: n('dispatchBonusUsed'),
|
||||
clearancesRequested: n('clearanceRequested'),
|
||||
switchedCars: sum,
|
||||
};
|
||||
}
|
||||
|
||||
describe('the tally counts every event exactly once (Gitea#16)', () => {
|
||||
for (const seed of [1, 7, 42, 116956197]) {
|
||||
it(`agrees with an independent count of the event stream — seed ${seed}`, () => {
|
||||
const { state, events } = playKeepingEvents(seed);
|
||||
const want = countIndependently(events);
|
||||
const t = state.tally;
|
||||
|
||||
assert.equal(t.trainsCompleted, want.trainsCompleted, 'trains through the Division');
|
||||
assert.equal(t.loadsCompleted, want.loadsCompleted, 'loads made up');
|
||||
assert.equal(t.unloadsCompleted, want.unloadsCompleted, 'loads broken');
|
||||
assert.equal(t.loadsStarted, want.loadsStarted, 'loads started');
|
||||
assert.equal(t.unloadsBegun, want.unloadsBegun, 'unloads begun');
|
||||
assert.equal(t.passengersBoarded, want.passengersBoarded, 'passengers boarded');
|
||||
assert.equal(t.passengersDetrained, want.passengersDetrained, 'passengers detrained');
|
||||
assert.equal(t.cardsDrawn, want.cardsDrawn, 'cards drawn');
|
||||
assert.equal(t.cardsPlayed, want.cardsPlayed, 'cards played');
|
||||
assert.equal(t.cardsDiscarded, want.cardsDiscarded, 'cards discarded');
|
||||
assert.equal(t.officeUpgrades, want.officeUpgrades, 'offices upgraded');
|
||||
assert.equal(t.flyingSwitches, want.flyingSwitches, 'flying switches');
|
||||
assert.equal(t.extrasStarted, want.extrasStarted, 'extras started');
|
||||
assert.equal(t.secondSections, want.secondSections, 'second sections');
|
||||
assert.equal(t.trainsHeld, want.trainsHeld, 'trains held');
|
||||
assert.equal(t.trainsDiverted, want.trainsDiverted, 'trains diverted');
|
||||
assert.equal(t.expediteFaults, want.expediteFaults, 'expedite faults');
|
||||
assert.equal(t.facilitiesUnjammed, want.facilitiesUnjammed, 'facilities unjammed');
|
||||
assert.equal(t.dispatchBonusesUsed, want.dispatchBonusesUsed, 'dispatch bonuses');
|
||||
assert.equal(t.clearancesRequested, want.clearancesRequested, 'clearances requested');
|
||||
assert.equal(t.carsCoupled + t.carsDropped, want.switchedCars, 'cars switched');
|
||||
});
|
||||
}
|
||||
|
||||
it('actually counted something — a tally of zeroes would pass the check above vacuously', () => {
|
||||
// The trap `stats.ts` warns about in its own doc comment: "this never happened" is a finding,
|
||||
// not something to scroll past. A fold hooked nowhere at all agrees perfectly with an
|
||||
// independent count of an event stream nobody looked at, so the exactly-once tests above cannot
|
||||
// catch it on their own.
|
||||
//
|
||||
// ASSERTED ON WHAT THE DEVELOPER BOT ACTUALLY DOES, which is not much: `node src/sim/harness.ts
|
||||
// 12 standard` means 1.1 Revenue per game at an 8% freight share and loses every game on the
|
||||
// revenue floor, and it goes whole games without coupling a single car. That is a known
|
||||
// property of the bot (TODO.md, Bot Performance) and not this fold's business — so this test
|
||||
// asserts on traffic and cards, which happen in every game, rather than on switching, which
|
||||
// would make it a bot-strength test wearing a statistics test's clothes.
|
||||
const { state, events } = playKeepingEvents(1);
|
||||
assert.ok(events.length > 500, `only ${events.length} events — the game barely ran`);
|
||||
assert.ok(state.tally.cardsDrawn > 0, 'no cards were drawn all game');
|
||||
assert.ok(state.tally.trainsCompleted > 0, 'no train ever left the Division');
|
||||
assert.ok(state.tally.cardsPlayed > 0, 'no card was ever played');
|
||||
});
|
||||
|
||||
it('splits Revenue into what was earned and what was given back', () => {
|
||||
// Reconciliation is the real assertion and it holds for any game, earned or not: gained minus
|
||||
// lost IS the score the engine kept. Seed 42 is named because it is one where Revenue actually
|
||||
// moves in both directions — it earns 1 and gives back 5 to a collision — so the two halves are
|
||||
// being told apart rather than both sitting at zero.
|
||||
for (const seed of [1, 7, 42]) {
|
||||
const { state } = playKeepingEvents(seed);
|
||||
const me = state.tally.byPlayer[0]!;
|
||||
assert.equal(
|
||||
me.revenueGained - me.revenueLost,
|
||||
state.players[0]!.revenue,
|
||||
`seed ${seed}: gained minus lost does not reconcile with the score the engine kept`,
|
||||
);
|
||||
}
|
||||
const { state } = playKeepingEvents(42);
|
||||
const me = state.tally.byPlayer[0]!;
|
||||
assert.ok(me.revenueGained > 0, 'seed 42 earned nothing — the gained half is not being counted');
|
||||
assert.ok(me.revenueLost > 0, 'seed 42 lost nothing — the lost half is not being counted');
|
||||
});
|
||||
|
||||
it('records a Circus set-up as the one-off it is, not as a streak', () => {
|
||||
/**
|
||||
* `trainStoodStill` is NOT "this train did not move this Stage". It fires only for a train whose
|
||||
* profile sets `stopEarnsPoint` — the X18 Circus — and `advance.ts` claims it once per train
|
||||
* with `stopPointClaimed`, so it can never fire twice for the same one.
|
||||
*
|
||||
* Gitea#16 asks for "longest engine sat on a siding" and its comment assumed this event would
|
||||
* answer it. It cannot, and a streak folded from it would have read "1 Stage" for ever. Pinned
|
||||
* here so that the day a real per-Stage signal is added, whoever adds it finds this test rather
|
||||
* than the old wrong assumption.
|
||||
*/
|
||||
const s = createGame({ id: 'g', seed: 1, config: baseConfig(), playerNames: ['Jesse'] });
|
||||
const feed = (e: GameEvent): void => tallyEvent(s, e);
|
||||
feed({ type: 'trainStoodStill', trainNumber: 18, where: '(0,0)' });
|
||||
assert.deepEqual(s.tally.circusStops, [{ trainNumber: 18, where: '(0,0)' }]);
|
||||
assert.ok(!('longestStand' in s.tally), 'a streak that cannot be computed is being reported');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the official result freezes the tally with it (Gitea#11 + #16)', () => {
|
||||
it('records the statistics as they stood when the timetable ran out', () => {
|
||||
const { state } = playKeepingEvents(1);
|
||||
assert.ok(state.official, 'no official result was recorded');
|
||||
// Nothing was played after the ending in this game, so the two agree — which is the check that
|
||||
// the freeze happens AFTER the last batch of events is folded rather than before it.
|
||||
assert.equal(state.official!.tally.trainsCompleted, state.tally.trainsCompleted);
|
||||
assert.ok(state.official!.tally.cardsDrawn > 0, 'the frozen tally is empty');
|
||||
});
|
||||
|
||||
it('keeps the frozen copy still while the live tally moves on', () => {
|
||||
const s = createGame({ id: 'g', seed: 1, config: baseConfig(), playerNames: ['Jesse'] });
|
||||
s.clock.day = s.config.days + 1;
|
||||
s.clock.stage = STAGES_PER_DAY;
|
||||
s.clock.phase = 'shiftChange';
|
||||
advance(s);
|
||||
const frozen = s.official!.tally.cardsDrawn;
|
||||
|
||||
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
|
||||
tallyEvent(s, { type: 'cardDrawn', player: 0, source: 'homeOffice', cardId: 'x' });
|
||||
assert.equal(s.tally.cardsDrawn, frozen + 1, 'the live tally did not move');
|
||||
assert.equal(s.official!.tally.cardsDrawn, frozen, 'the frozen tally moved with it');
|
||||
});
|
||||
});
|
||||
+106
-5
@@ -21,14 +21,16 @@ import { URLSearchParams as NodeURLSearchParams } from 'node:url';
|
||||
import { cardDescription, cardName, describeIntent, variantLabel } from '../src/sim/view.ts';
|
||||
import { variantsFor } from '../src/engine/track.ts';
|
||||
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
|
||||
import { ENHANCEMENT_RULES } from '../src/engine/content.ts';
|
||||
import { dayEndHtml, facilitiesHtml, timetableHtml } from '../src/web/panels.ts';
|
||||
import { ENHANCEMENT_RULES, STAGES_PER_DAY } from '../src/engine/content.ts';
|
||||
import { dayEndHtml, facilitiesHtml, resultsHtml, timetableHtml } from '../src/web/panels.ts';
|
||||
import { turnChartHtml } from '../src/sim/turnchart.ts';
|
||||
import { fieldSelectors } from '../src/web/settings-form.ts';
|
||||
import { record, renderHtml } from '../src/sim/replay.ts';
|
||||
import type { Frame } from '../src/sim/view.ts';
|
||||
import { snapshot } from '../src/sim/view.ts';
|
||||
import { createGame as createEngineGame } from '../src/engine/setup.ts';
|
||||
import { advance as advanceEngine } from '../src/engine/advance.ts';
|
||||
import type { GameConfig } from '../src/engine/state.ts';
|
||||
import {
|
||||
overHandLimit,
|
||||
actionGroups,
|
||||
@@ -104,6 +106,18 @@ describe('the browser game plays', () => {
|
||||
it('reaches the end of a game through the same calls the page makes', () => {
|
||||
const { game, turns } = playThrough(77);
|
||||
assert.ok(turns > 50, `only ${turns} decisions — the game stalled`);
|
||||
|
||||
/**
|
||||
* The timetable runs out and the game STOPS TO ASK rather than ending (Gitea#11) — `currentActor`
|
||||
* is null there, which is where `playThrough` breaks. Declining through `submit` finishes it,
|
||||
* which is worth doing here rather than merely asserting the pause: this test exists to prove a
|
||||
* whole game is playable through the calls the page makes, and since Gitea#11 the last of those
|
||||
* calls is the vote.
|
||||
*/
|
||||
assert.equal(game.state.status, 'awaitingExtension');
|
||||
assert.ok(game.state.official !== null, 'an ended game must have recorded its official result');
|
||||
assert.ok(submit(game, { type: 'game.extend', player: 0, agree: false }, 0), 'the page cannot decline');
|
||||
|
||||
assert.equal(game.state.status, 'finished');
|
||||
assert.ok(game.state.outcome !== null, 'a finished game must have an outcome');
|
||||
});
|
||||
@@ -1932,8 +1946,10 @@ describe('the static build', () => {
|
||||
const asked = new Set([...src.matchAll(/\$\('([a-zA-Z][\w-]*)'\)/g)].map((m) => m[1]!));
|
||||
const html = readFileSync(join(dist, 'play.html'), 'utf8');
|
||||
const present = new Set([...html.matchAll(/id="([a-zA-Z][\w-]*)"/g)].map((m) => m[1]!));
|
||||
// `again` is created by the game-over screen before it is looked up.
|
||||
present.add('again');
|
||||
// Created by the ending screen (`renderEnding`) before they are looked up, so they are never in
|
||||
// the served HTML: `again` starts a new game, `results` reopens the results dialog, and the two
|
||||
// `extend-*` buttons are the extension vote (Gitea#11).
|
||||
for (const id of ['again', 'results', 'extend-yes', 'extend-no']) present.add(id);
|
||||
for (const id of asked) {
|
||||
assert.ok(present.has(id), `main.ts asks for #${id}, which the page does not contain`);
|
||||
}
|
||||
@@ -3283,12 +3299,97 @@ describe('the Day rolling over says so (Gitea#10)', () => {
|
||||
|
||||
it('gives the page the dialog to fill', () => {
|
||||
const html = readFileSync(join(dist, 'play.html'), 'utf8');
|
||||
for (const id of ['dayenddlg', 'dayendbody']) {
|
||||
for (const id of ['dayenddlg', 'dayendbody', 'resultsdlg', 'resultsbody']) {
|
||||
assert.ok(html.includes(`id="${id}"`), `play.html has no #${id}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('the end-of-game results screen (Gitea#16)', () => {
|
||||
/**
|
||||
* A finished game, built by running the clock off the end of a real one rather than by hand — the
|
||||
* screen reads `official`, and only the engine writes that.
|
||||
*/
|
||||
const finished = (over: Partial<GameConfig> = {}, names = ['Solitaire']): Frame => {
|
||||
const s = createEngineGame({
|
||||
id: 'results', seed: 4021,
|
||||
config: {
|
||||
mode: 'solitaire', days: 5, minCombinedRevenue: 12,
|
||||
maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
...over,
|
||||
},
|
||||
playerNames: names,
|
||||
});
|
||||
s.clock.day = s.config.days + 1;
|
||||
s.clock.stage = STAGES_PER_DAY;
|
||||
s.clock.phase = 'shiftChange';
|
||||
advanceEngine(s);
|
||||
return snapshot(s, [], null);
|
||||
};
|
||||
|
||||
it('never prints a raw enum at the player', () => {
|
||||
// The bug the issue opens on: `GAME OVER — revenueFloor` is an internal identifier, shown at
|
||||
// the one moment the game has the player's whole attention.
|
||||
for (const f of [finished(), finished({ minCombinedRevenue: 0 })]) {
|
||||
const html = resultsHtml(f);
|
||||
for (const raw of ['revenueFloor', 'daysElapsed', 'collisionFloor']) {
|
||||
assert.ok(!html.includes(raw), `the results screen prints the raw reason "${raw}"`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('says why the game ended, in a sentence, with the game\'s own numbers in it', () => {
|
||||
const html = resultsHtml(finished());
|
||||
assert.ok(html.includes('closed short'), `the revenue-floor ending is not explained:\n${html}`);
|
||||
assert.ok(html.includes('<b>12</b>'), 'the floor that was missed is not named');
|
||||
});
|
||||
|
||||
it('reports the rules the game was actually dealt under', () => {
|
||||
const html = resultsHtml(finished({ mode: 'coop' }, ['Ada', 'Bo']));
|
||||
assert.ok(html.includes('The rules in play'), 'the rules section is missing');
|
||||
assert.ok(html.includes('Co-op'), 'the mode is not reported');
|
||||
assert.ok(html.includes('per load'), 'the pay rates are not reported');
|
||||
});
|
||||
|
||||
it('reports the railroad from the tally, not from nothing', () => {
|
||||
const html = resultsHtml(finished());
|
||||
assert.ok(html.includes('Trains through the Division'), 'the tally section is missing');
|
||||
});
|
||||
|
||||
it('names the winner the OFFICIAL result named, not whoever leads now (Gitea#11)', () => {
|
||||
const f = finished({ mode: 'competitive', minCombinedRevenue: 0 }, ['Ada', 'Bo']);
|
||||
// Ada won at the timetable; Bo overtakes during extended play. The frozen result stands.
|
||||
f.official = {
|
||||
day: 5,
|
||||
outcome: { result: 'win', winner: 0, reason: 'daysElapsed' },
|
||||
revenues: [9, 2],
|
||||
collisionsTotal: 0,
|
||||
tally: f.tally,
|
||||
};
|
||||
f.extraDays = 3;
|
||||
f.players = [
|
||||
{ index: 0, seat: 0, name: 'Ada', revenue: 9, hand: 3 },
|
||||
{ index: 1, seat: 1, name: 'Bo', revenue: 40, hand: 2 },
|
||||
];
|
||||
const html = resultsHtml(f);
|
||||
assert.ok(html.includes('Ada takes the Division'), `the official winner is not the headline:\n${html}`);
|
||||
assert.ok(html.includes('winner'), 'nobody is marked as the winner in the standings');
|
||||
assert.ok(html.includes('After the timetable'), 'extended play is not reported at all');
|
||||
assert.ok(html.includes('3 more Days'), 'the extra Days are not counted');
|
||||
assert.ok(
|
||||
html.indexOf('Ada takes the Division') < html.indexOf('After the timetable'),
|
||||
'the informational section is above the official result',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves out the extended-play section entirely when nothing was extended', () => {
|
||||
assert.ok(!resultsHtml(finished()).includes('After the timetable'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('every file the build needs is actually in the repo (regression)', () => {
|
||||
it('does not gitignore a source page', () => {
|
||||
// REGRESSION. `.gitignore` carried `replay*.html` to catch the throwaway files generated at the
|
||||
|
||||
Reference in New Issue
Block a user