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
|
## 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
|
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
|
roughly a third of the event types are never reduced at all. Anything that needs to rebuild a game
|
||||||
replays the intents.
|
replays the intents.
|
||||||
- **Never call `Math.random()`.** One ambient random call silently breaks replay.
|
- **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,
|
- **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
|
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
|
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
|
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.
|
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
|
30. ~~**Close the Gitea issues by hand, each with a comment naming the commit that fixed it.**~~ —
|
||||||
by Jesse 2026-08-25. `Closes #<n>` is in the main commit message and will auto-close #2, #8, #9
|
done, and done again for v0.7.2 / v0.4.9h (2026-08-26). #2, #6, #8, #9 and #10 carry their
|
||||||
and #10 when it reaches the default branch, but auto-closing leaves the issue with no record of
|
comments from the v0.7.1 release, including the pointer on #6 saying #9 superseded it. #3, #14,
|
||||||
WHICH commit or WHICH release answered it — and #9 supersedes #6, which nothing on #6 will ever
|
#15, #17 and #18 now carry theirs, each naming `2ab25e3` and `e9683cc` and the version each
|
||||||
say. So each issue wants a comment of its own: the commit SHA on both lines, the version each
|
shipped in — and, where the fix was not what the report implied, the ruling that decided it:
|
||||||
shipped in (0.7.1 and 0.4.9g), and for #2 and #9 the ruling that decided it, since neither was
|
#15 was REVERSED on review (the placement is legal; what was confirmed is that no train crosses
|
||||||
fixed the way the report implies. #6 wants a comment too, pointing at #9. The token and the API
|
the gap), and #14's comment lists the ten unbuilt cards that are held out, so they do not vanish
|
||||||
calls are in the workspace's `AGENTS.local.md`.
|
along with the issue.
|
||||||
|
|
||||||
31. **Bump the StartOS wrapper to 0.7.1.** `station-master-startos` is clean, on `master`, pinned to
|
**Keep doing this.** Auto-closing leaves an issue with no record of which commit or which release
|
||||||
`v0.7.0` and versioned `0.7.0:3`. Once v0.7.1 is tagged: move the submodule pin to the tag, set
|
answered it. The token and the API calls are in the workspace's `AGENTS.local.md`.
|
||||||
`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
|
31. ~~**Bump the StartOS wrapper to 0.7.2.**~~ — done 2026-08-26 (`1bfea8d`). Submodule pinned to
|
||||||
migration** — `current.ts`'s `migrations.up` is empty, so nothing is carried from 0.7.0, and a
|
`v0.7.2`, `current.ts` at `0.7.2:0`, release notes rewritten in all five locales, `README.md` and
|
||||||
0.7.0 save replays under 0.7.1 unchanged (its intents cannot contain a Timetabled discard,
|
`instructions.md` updated. No new version file and no migration. Verified: `npm run check` clean
|
||||||
because that was illegal when they were recorded). `instructions.md` and `README.md` both name
|
and `make x86` packs as `v0.7.2:0`.
|
||||||
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
|
**This is the first release that does NOT carry games in progress**, and the release notes lead
|
||||||
trap: the game pushes to `main`, the wrapper to `master`.
|
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",
|
"name": "station-master",
|
||||||
"version": "0.7.2",
|
"version": "0.7.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Station Master — a railroad operations game",
|
"description": "Station Master — a railroad operations game",
|
||||||
|
|||||||
+93
-9
@@ -38,8 +38,9 @@ import type { GameEvent } from './events.ts';
|
|||||||
// the legality test cannot disagree about which train is being assembled.
|
// the legality test cannot disagree about which train is being assembled.
|
||||||
import { areaAtSeat, areaOf, trainNeedingCars } from './apply.ts';
|
import { areaAtSeat, areaOf, trainNeedingCars } from './apply.ts';
|
||||||
import { legalActions } from './legal.ts';
|
import { legalActions } from './legal.ts';
|
||||||
import type { CrewTray, DivisionNode, GameState, PlayerIndex, RollingStock, SeatIndex, TrayId } from './state.ts';
|
import type { CrewTray, DivisionNode, GameState, Outcome, PlayerIndex, RollingStock, SeatIndex, TrayId } from './state.ts';
|
||||||
import { coordKey, freshTurns, playerAtSeat, playerLeftOf, pooled, subdivisions, totalRevenue, turnOf } 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 = {
|
export type AdvanceResult = {
|
||||||
events: GameEvent[];
|
events: GameEvent[];
|
||||||
@@ -71,10 +72,58 @@ const step = (d: Direction): number => (d === 'east' ? 1 : -1);
|
|||||||
// advance
|
// 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 {
|
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[] = [];
|
const events: GameEvent[] = [];
|
||||||
if (s.status === 'finished') return { events, needsInput: false };
|
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).
|
// The Superintendent's clearance ruling interrupts the Mainline Phase (§8.1).
|
||||||
if (s.clock.pendingDecision !== null) return { events, needsInput: true };
|
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;
|
s.config.maxCollisionsTotal > 0 && s.collisionsTotal >= s.config.maxCollisionsTotal;
|
||||||
if (perDayBreach || totalBreach) {
|
if (perDayBreach || totalBreach) {
|
||||||
s.status = 'finished';
|
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' };
|
s.outcome = { result: 'loss', winner: null, reason: 'collisionFloor' };
|
||||||
return { events, needsInput: false };
|
return { events, needsInput: false };
|
||||||
}
|
}
|
||||||
@@ -1356,28 +1413,55 @@ function rotateSeats(s: GameState, events: GameEvent[]): void {
|
|||||||
|
|
||||||
function checkVictory(s: GameState, _events: GameEvent[]): boolean {
|
function checkVictory(s: GameState, _events: GameEvent[]): boolean {
|
||||||
const daysElapsed = s.clock.day - 1;
|
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.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';
|
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);
|
const combined = totalRevenue(s);
|
||||||
if (s.config.minCombinedRevenue > 0 && combined < s.config.minCombinedRevenue) {
|
if (s.config.minCombinedRevenue > 0 && combined < s.config.minCombinedRevenue) {
|
||||||
s.outcome = { result: 'loss', winner: null, reason: 'revenueFloor' };
|
return { result: 'loss', winner: null, reason: 'revenueFloor' };
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (s.config.mode === 'coop') {
|
if (s.config.mode === 'coop') {
|
||||||
s.outcome = { result: 'win', winner: null, reason: 'daysElapsed' };
|
return { result: 'win', winner: null, reason: 'daysElapsed' };
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const best = Math.max(...s.players.map((p) => p.revenue));
|
const best = Math.max(...s.players.map((p) => p.revenue));
|
||||||
s.outcome = {
|
return {
|
||||||
result: 'win',
|
result: 'win',
|
||||||
winner: s.players.findIndex((p) => p.revenue === best),
|
winner: s.players.findIndex((p) => p.revenue === best),
|
||||||
reason: 'daysElapsed',
|
reason: 'daysElapsed',
|
||||||
};
|
};
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import type {
|
|||||||
TrayId,
|
TrayId,
|
||||||
TurnoutOrientation,
|
TurnoutOrientation,
|
||||||
} from './state.ts';
|
} from './state.ts';
|
||||||
|
import { tallyEvent } from './tally.ts';
|
||||||
import { createRng } from './rng.ts';
|
import { createRng } from './rng.ts';
|
||||||
import {
|
import {
|
||||||
carsOn,
|
carsOn,
|
||||||
@@ -769,6 +770,23 @@ export function passengerRefusal(
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCode | null {
|
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';
|
if (s.status !== 'active') return 'WRONG_PHASE';
|
||||||
|
|
||||||
// The clearance ruling is the one intent that arrives out of turn order: it interrupts the
|
// 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[] {
|
function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||||
switch (i.type) {
|
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':
|
case 'localOps.choose':
|
||||||
return [{ type: 'localOpsOptionChosen', player, option: i.option }];
|
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 {
|
export function reduce(s: GameState, e: GameEvent): void {
|
||||||
switch (e.type) {
|
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':
|
case 'localOpsOptionChosen':
|
||||||
turnOf(s, e.player).option = e.option;
|
turnOf(s, e.player).option = e.option;
|
||||||
break;
|
break;
|
||||||
@@ -3052,6 +3115,9 @@ export function applyIntent(s: GameState, player: PlayerIndex, i: Intent): Apply
|
|||||||
|
|
||||||
const events = execute(s, player, i);
|
const events = execute(s, player, i);
|
||||||
for (const e of events) reduce(s, e);
|
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 };
|
return { ok: true, events };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+18
-1
@@ -195,6 +195,23 @@ export type GameEvent =
|
|||||||
| { type: 'unloadBegan'; player: PlayerIndex; at: GridCoord; carType: CarType; carIndex: number }
|
| { type: 'unloadBegan'; player: PlayerIndex; at: GridCoord; carType: CarType; carIndex: number }
|
||||||
// -- consequences
|
// -- consequences
|
||||||
| { type: 'revenueChanged'; player: PlayerIndex; delta: number; total: number; reason: string }
|
| { 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'];
|
export type EventType = GameEvent['type'];
|
||||||
|
|||||||
+27
-2
@@ -147,7 +147,28 @@ export type Intent =
|
|||||||
| { type: 'laborer.startLoad'; at: GridCoord }
|
| { type: 'laborer.startLoad'; at: GridCoord }
|
||||||
| { type: 'laborer.advanceLoad'; at: GridCoord; box: number }
|
| { type: 'laborer.advanceLoad'; at: GridCoord; box: number }
|
||||||
| { type: 'laborer.beginUnload'; at: GridCoord; carIndex: 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'];
|
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;
|
* §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.
|
* 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 };
|
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[] {
|
function candidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||||
const out: 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).
|
// The clearance ruling arrives out of turn order and goes to the Superintendent (§8.1).
|
||||||
if (s.clock.pendingDecision !== null) {
|
if (s.clock.pendingDecision !== null) {
|
||||||
out.push({ type: 'mainline.clearance', allow: true });
|
out.push({ type: 'mainline.clearance', allow: true });
|
||||||
|
|||||||
+5
-1
@@ -43,7 +43,7 @@ import type {
|
|||||||
TrackCard,
|
TrackCard,
|
||||||
TrayId,
|
TrayId,
|
||||||
} from './state.ts';
|
} from './state.ts';
|
||||||
import { coordKey, freshTurns } from './state.ts';
|
import { coordKey, emptyTally, freshTurns } from './state.ts';
|
||||||
|
|
||||||
export type SetupOptions = {
|
export type SetupOptions = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -434,6 +434,10 @@ export function createGame(opts: SetupOptions): GameState {
|
|||||||
collisionsTotal: 0,
|
collisionsTotal: 0,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
outcome: null,
|
outcome: null,
|
||||||
|
extraDays: 0,
|
||||||
|
extensionVotes: players.map(() => null),
|
||||||
|
official: null,
|
||||||
|
tally: emptyTally(playerCount),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+220
-1
@@ -684,6 +684,139 @@ export type Outcome = {
|
|||||||
reason: OutcomeReason;
|
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.
|
* 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;
|
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.
|
* WHICH WAY TO DRAW THE ENGINE — east or west, for every train, everywhere.
|
||||||
*
|
*
|
||||||
@@ -866,8 +1059,34 @@ export type GameState = {
|
|||||||
collisionsToday: number;
|
collisionsToday: number;
|
||||||
/** §3.4 — never reset; checked against `config.maxCollisionsTotal`. */
|
/** §3.4 — never reset; checked against `config.maxCollisionsTotal`. */
|
||||||
collisionsTotal: number;
|
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;
|
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 { legalActions } from '../engine/legal.ts';
|
||||||
import type { Intent } from '../engine/intents.ts';
|
import type { Intent } from '../engine/intents.ts';
|
||||||
import type { GameConfig, PlayerIndex } from '../engine/state.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 type { Game, Menu } from '../web/game.ts';
|
||||||
import { deltaFrame } from '../sim/frame-delta.ts';
|
import { deltaFrame } from '../sim/frame-delta.ts';
|
||||||
import type { FrameDelta } 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
|
* 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.
|
* 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 {
|
function driveBots(): void {
|
||||||
let guard = 0;
|
let guard = 0;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
@@ -281,7 +327,7 @@ function buildSession(
|
|||||||
settleTiming();
|
settleTiming();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
driveBots();
|
driveBotTurns();
|
||||||
// Whatever the opening bot turns earned belongs to a game nobody was connected to yet — dropped
|
// 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
|
// 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.)
|
// 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).
|
// 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 (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
|
// 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
|
// "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 };
|
if (code) return { accepted: false, code };
|
||||||
|
|
||||||
const drawnBefore = game.justDrawn;
|
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) {
|
if (game.justDrawn !== drawnBefore && game.justDrawn !== null) {
|
||||||
lastDraw = { seat, cardId: game.justDrawn };
|
lastDraw = { seat, cardId: game.justDrawn };
|
||||||
}
|
}
|
||||||
@@ -327,8 +383,9 @@ function buildSession(
|
|||||||
const timing = settleTiming();
|
const timing = settleTiming();
|
||||||
// Any bot due to act now plays out entirely before this push goes back — the delta mechanism
|
// 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
|
// 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.
|
// in one push regardless of how many turns that took. Votes included, since Gitea#11: a human
|
||||||
driveBots();
|
// agreeing to another Day is exactly the move the bots are waiting on to agree themselves.
|
||||||
|
driveBotTurns();
|
||||||
return { accepted: true, pushes: pushesForAll(), timing };
|
return { accepted: true, pushes: pushesForAll(), timing };
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -338,6 +395,8 @@ function buildSession(
|
|||||||
config: game.state.config,
|
config: game.state.config,
|
||||||
playerNames: [...playerNames],
|
playerNames: [...playerNames],
|
||||||
history: [...game.history],
|
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',
|
status: game.state.status === 'finished' ? 'finished' : 'active',
|
||||||
createdAt,
|
createdAt,
|
||||||
botSeats: [...botSeats],
|
botSeats: [...botSeats],
|
||||||
@@ -351,6 +410,8 @@ function buildSession(
|
|||||||
playerCount: playerNames.length,
|
playerCount: playerNames.length,
|
||||||
playerNames: [...playerNames],
|
playerNames: [...playerNames],
|
||||||
botSeats: [...botSeats],
|
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',
|
status: game.state.status === 'finished' ? 'finished' : 'active',
|
||||||
createdAt,
|
createdAt,
|
||||||
lastMoveAt,
|
lastMoveAt,
|
||||||
|
|||||||
@@ -138,6 +138,19 @@ export function makeDeveloperBot(tweaks: BotTweaks): BotPolicy {
|
|||||||
|
|
||||||
choose(s, player, options) {
|
choose(s, player, options) {
|
||||||
lastReason = 'no specific reason — first legal option';
|
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);
|
const clearance = ruleOnClearance(options);
|
||||||
if (clearance) return because('the Superintendent must rule on a following train', clearance);
|
if (clearance) return because('the Superintendent must rule on a following train', clearance);
|
||||||
|
|
||||||
@@ -1861,6 +1874,36 @@ export function playGame(
|
|||||||
tally(pumpFn(s));
|
tally(pumpFn(s));
|
||||||
if (s.status === 'finished') break;
|
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 =
|
const actor =
|
||||||
s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
|
s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
|
||||||
if (actor === null) break;
|
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}` };
|
: { tone: 'good', text: `+${e.delta} Revenue (now ${e.total}) — ${e.reason}` };
|
||||||
case 'phaseEnded':
|
case 'phaseEnded':
|
||||||
return { tone: 'quiet', text: `Player ${e.player} finished ${phaseLabel(e.phase)}` };
|
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 {
|
export function playForReplay(seed: number, policy: BotPolicy, maxTurns = 50_000): PlayedGame {
|
||||||
const game = newGame(seed);
|
const game = newGame(seed);
|
||||||
for (let t = 0; t < maxTurns; t++) {
|
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);
|
const actor = currentActor(game);
|
||||||
if (actor === null) break;
|
if (actor === null) break;
|
||||||
const options = legalActions(game.state, actor);
|
const options = legalActions(game.state, actor);
|
||||||
|
|||||||
+44
-1
@@ -398,6 +398,24 @@ export type Frame = {
|
|||||||
collisionsTotal: number;
|
collisionsTotal: number;
|
||||||
status: GameState['status'];
|
status: GameState['status'];
|
||||||
outcome: GameState['outcome'];
|
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).
|
* 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':
|
case 'maneuver.redFlags':
|
||||||
return `set Red Flags to protect ${trainName(s, i.trayId)} — an approaching train must stop short`;
|
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':
|
case 'maneuver.flyingSwitch':
|
||||||
return `Flying Switch ${i.count} car(s) into ${at(i.to)}`;
|
return `Flying Switch ${i.count} car(s) into ${at(i.to)}`;
|
||||||
case 'mainline.clearance': {
|
case 'mainline.clearance': {
|
||||||
@@ -1074,6 +1098,12 @@ export function describeIntent(s: GameState, i: Intent): string {
|
|||||||
return 'play your red flag';
|
return 'play your red flag';
|
||||||
case 'maneuver.redFlags':
|
case 'maneuver.redFlags':
|
||||||
return `set Red Flags to protect ${trainName(s, i.trayId)} — an approaching train must stop short`;
|
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: {
|
default: {
|
||||||
// Every Intent now has a sentence, so `i` narrows to never here. Keeping the assignment makes
|
// 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
|
// 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,
|
collisionsTotal: s.collisionsTotal,
|
||||||
status: s.status,
|
status: s.status,
|
||||||
outcome: s.outcome,
|
outcome: s.outcome,
|
||||||
|
extraDays: s.extraDays,
|
||||||
|
extensionVotes: [...s.extensionVotes],
|
||||||
|
official: s.official,
|
||||||
|
tally: s.tally,
|
||||||
players: s.players.map((p) => ({
|
players: s.players.map((p) => ({
|
||||||
index: p.index,
|
index: p.index,
|
||||||
seat: seatOf(s, 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.
|
* view of its own. `0` means no floor is configured — nothing to pace against.
|
||||||
*/
|
*/
|
||||||
function objectiveOf(s: GameState, viewer: PlayerIndex): Frame['objective'] {
|
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 revenue = s.players[viewer]?.revenue ?? 0;
|
||||||
const daysLeft = Math.max(0, days - s.clock.day + 1);
|
const daysLeft = Math.max(0, days - s.clock.day + 1);
|
||||||
const elapsed = days - daysLeft + 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;
|
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 {
|
* Intents any seat may send regardless of whose turn it is — and, for `game.extend`, regardless of
|
||||||
const actor = currentActor(game);
|
* 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;
|
if (actor === null) return false;
|
||||||
|
|
||||||
const result = applyIntent(game.state, actor, intent);
|
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 {
|
export function fromSave(save: Save, config: GameConfig = SOLO_CONFIG): Game {
|
||||||
const game = newGame(save.seed, configFor(save, config));
|
const game = newGame(save.seed, configFor(save, config));
|
||||||
for (const intent of save.history) {
|
for (const intent of save.history) {
|
||||||
const actor = currentActor(game);
|
const actor = replayActor(game, intent);
|
||||||
if (actor === null) break;
|
if (actor === null) break;
|
||||||
const result = applyIntent(game.state, actor, intent);
|
const result = applyIntent(game.state, actor, intent);
|
||||||
if (!result.ok) break;
|
if (!result.ok) break;
|
||||||
@@ -1218,7 +1251,7 @@ export function fromMultiplayerSave(
|
|||||||
): { game: Game; stopped: ReplayStop | null } {
|
): { game: Game; stopped: ReplayStop | null } {
|
||||||
const game = newMultiplayerGame(seed, config, playerNames);
|
const game = newMultiplayerGame(seed, config, playerNames);
|
||||||
for (const [index, intent] of history.entries()) {
|
for (const [index, intent] of history.entries()) {
|
||||||
const actor = currentActor(game);
|
const actor = replayActor(game, intent);
|
||||||
if (actor === null) {
|
if (actor === null) {
|
||||||
return { game, stopped: { index, intent, code: 'NO_ACTOR' } };
|
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 type { Frame } from '../sim/view.ts';
|
||||||
import { seatLabel } from '../sim/view.ts';
|
import { seatLabel } from '../sim/view.ts';
|
||||||
import type { Menu, Save } from './game.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 { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
|
||||||
import { playCue } from './sound.ts';
|
import { playCue } from './sound.ts';
|
||||||
import {
|
import {
|
||||||
@@ -916,7 +916,16 @@ function render(): void {
|
|||||||
* figure came from a target that is itself an open question.
|
* figure came from a target that is itself an open question.
|
||||||
*/
|
*/
|
||||||
const obj = $('objective');
|
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';
|
obj.className = 'pace';
|
||||||
// The seed is never sent to a remote client at all (it would leak every future shuffle and roll,
|
// 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.
|
// `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;
|
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(
|
function renderActions(
|
||||||
menu: Menu,
|
menu: Menu,
|
||||||
f: Frame,
|
f: Frame,
|
||||||
@@ -1400,18 +1517,12 @@ function renderActions(
|
|||||||
const el = $('actions');
|
const el = $('actions');
|
||||||
|
|
||||||
if (f.status !== 'active') {
|
if (f.status !== 'active') {
|
||||||
const o = f.outcome;
|
renderEnding(el, f);
|
||||||
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 = '';
|
|
||||||
};
|
|
||||||
return;
|
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) {
|
if (menu.direct.length === 0 && menu.placeable.length === 0) {
|
||||||
el.innerHTML = '<div class="dim">nothing to decide — the engine is running the Division</div>';
|
el.innerHTML = '<div class="dim">nothing to decide — the engine is running the Division</div>';
|
||||||
|
|||||||
+309
-26
@@ -168,27 +168,69 @@ export function timetableHtml(f: Frame, justSet: number | null): string {
|
|||||||
*/
|
*/
|
||||||
export function dayEndHtml(f: Frame): string {
|
export function dayEndHtml(f: Frame): string {
|
||||||
const ended = f.day - 1;
|
const ended = f.day - 1;
|
||||||
const left = f.days - ended;
|
const left = f.days + f.extraDays - ended;
|
||||||
const standings = [...f.players]
|
|
||||||
|
const ahead =
|
||||||
|
left <= 0
|
||||||
|
? '<p>That was the last Day on the timetable.</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 +
|
||||||
|
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)
|
.sort((a, b) => b.revenue - a.revenue || a.seat - b.seat)
|
||||||
.map(
|
.map((p) => {
|
||||||
(p) =>
|
const marks =
|
||||||
`<tr${p.index === f.viewer ? ' class="you"' : ''}><td>${esc(p.name)}` +
|
(p.index === f.viewer ? ' <span class="dim">(you)</span>' : '') +
|
||||||
`${p.index === f.viewer ? ' <span class="dim">(you)</span>' : ''}</td>` +
|
(p.index === winner ? ' <span class="wins">— winner</span>' : '');
|
||||||
`<td class="num">${p.revenue}</td></tr>`,
|
return (
|
||||||
)
|
`<tr${p.index === f.viewer ? ' class="you"' : ''}><td>${esc(p.name)}${marks}</td>` +
|
||||||
|
`<td class="num">${p.revenue}</td></tr>`
|
||||||
|
);
|
||||||
|
})
|
||||||
.join('');
|
.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
|
* The target is a COMBINED floor in every mode that sets one, so it is reported against the whole
|
||||||
// target reads as a hopeless position when the table may be comfortably ahead.
|
* 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 combined = f.players.reduce((n, p) => n + p.revenue, 0);
|
||||||
const target =
|
const met = combined >= f.minCombinedRevenue;
|
||||||
f.minCombinedRevenue > 0
|
return (
|
||||||
? `<p>Combined Revenue <b>${combined}</b> against a target of <b>${f.minCombinedRevenue}</b>.</p>`
|
`<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.
|
* 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
|
* Two conditions, both of them `advance.ts`'s own: `0` on a dial turns that check off, and the
|
||||||
@@ -196,24 +238,249 @@ export function dayEndHtml(f: Frame): string {
|
|||||||
* its config and enforces neither, so reporting a collision budget there would put a rule on
|
* its config and enforces neither, so reporting a collision budget there would put a rule on
|
||||||
* screen that this game does not have.
|
* screen that this game does not have.
|
||||||
*/
|
*/
|
||||||
|
function collisionsHtml(f: Frame): string {
|
||||||
const scoredOnCollisions =
|
const scoredOnCollisions =
|
||||||
(f.mode === 'competitive' || f.mode === 'coop') &&
|
(f.mode === 'competitive' || f.mode === 'coop') &&
|
||||||
(f.maxCollisionsTotal > 0 || f.maxCollisionsPerDay > 0);
|
(f.maxCollisionsTotal > 0 || f.maxCollisionsPerDay > 0);
|
||||||
const collisions = scoredOnCollisions
|
return scoredOnCollisions
|
||||||
? `<p>Collisions: <b>${f.collisionsToday}</b> today, <b>${f.collisionsTotal}</b> in all.</p>`
|
? `<p>Collisions: <b>${f.collisionsToday}</b> today, <b>${f.collisionsTotal}</b> in all.</p>`
|
||||||
: '';
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
const ahead =
|
/**
|
||||||
left <= 0
|
* WHY THE GAME ENDED, as a sentence (Gitea#16).
|
||||||
? '<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>`;
|
* 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 (
|
return (
|
||||||
`<h3 class="dayend-h">Day ${ended} has ended</h3>` +
|
`<h3 class="dayend-h res-${o.result}">${headline}</h3>` +
|
||||||
ahead +
|
`<p>${reasonSentence(frozen, o, officialDay)}</p>` +
|
||||||
`<table class="dayend-t"><tbody>${standings}</tbody></table>` +
|
result +
|
||||||
target +
|
standingsHtml(frozen, o.winner) +
|
||||||
collisions
|
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 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 .num{text-align:right;font-variant-numeric:tabular-nums;font-weight:700;padding-right:0}
|
||||||
.dayend-t .you td{color:#8fd6a0}
|
.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{padding:9px;border-radius:5px;font-weight:700;margin-bottom:8px}
|
||||||
.over.win{background:rgba(40,140,60,.35)}
|
.over.win{background:rgba(40,140,60,.35)}
|
||||||
.over.loss{background:rgba(160,60,60,.3)}
|
.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 */
|
/* cards, log, blocked */
|
||||||
.card.gone{opacity:.35;text-decoration:line-through}
|
.card.gone{opacity:.35;text-decoration:line-through}
|
||||||
.subj{display:block;width:100%;margin:2px 0}
|
.subj{display:block;width:100%;margin:2px 0}
|
||||||
@@ -890,6 +898,18 @@ ul.blocked li{padding:2px 0}
|
|||||||
</form>
|
</form>
|
||||||
</dialog>
|
</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.
|
<!-- THE HANDOFF, between `Lobby.Start` and the first Frame.
|
||||||
`beginRemote` used to write "… connecting to the game" into `#presence` — the DISCONNECT banner,
|
`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
|
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,
|
currentActor,
|
||||||
fromSave,
|
fromSave,
|
||||||
handPlayable,
|
handPlayable,
|
||||||
|
isOutOfTurn,
|
||||||
newGame,
|
newGame,
|
||||||
overHandLimit,
|
overHandLimit,
|
||||||
submit,
|
submit,
|
||||||
@@ -163,7 +164,9 @@ export function createLocalSession(seed: number, options?: NewGameOptions): Loca
|
|||||||
overHandLimit: () => overHandLimit(game),
|
overHandLimit: () => overHandLimit(game),
|
||||||
handPlayable: () => handPlayable(game),
|
handPlayable: () => handPlayable(game),
|
||||||
submit: async (intent: Intent) => {
|
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();
|
if (ok) changed();
|
||||||
return ok;
|
return ok;
|
||||||
},
|
},
|
||||||
|
|||||||
+24
-5
@@ -66,6 +66,21 @@ function playToCompletion(s: GameState, seed = 1, maxTurns = 20_000): PlayStats
|
|||||||
tally(pump(s));
|
tally(pump(s));
|
||||||
if (s.status === 'finished') break;
|
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;
|
const actor = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
|
||||||
if (actor === null) break;
|
if (actor === null) break;
|
||||||
|
|
||||||
@@ -544,9 +559,13 @@ describe('victory conditions (§3, Gap 10e) — unified 2026-08-20', () => {
|
|||||||
s.clock.phase = 'shiftChange';
|
s.clock.phase = 'shiftChange';
|
||||||
s.players[0]!.revenue = 0;
|
s.players[0]!.revenue = 0;
|
||||||
advance(s);
|
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!.result, 'loss');
|
||||||
assert.equal(s.outcome!.reason, 'revenueFloor');
|
assert.equal(s.outcome!.reason, 'revenueFloor');
|
||||||
|
assert.equal(s.official!.outcome.reason, 'revenueFloor');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('wins a timed Solitaire game that clears minCombinedRevenue', () => {
|
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.clock.phase = 'shiftChange';
|
||||||
s.players[0]!.revenue = 10;
|
s.players[0]!.revenue = 10;
|
||||||
advance(s);
|
advance(s);
|
||||||
assert.equal(s.status, 'finished');
|
assert.equal(s.status, 'awaitingExtension');
|
||||||
assert.equal(s.outcome!.result, 'win');
|
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.clock.phase = 'shiftChange';
|
||||||
s.players[0]!.revenue = 0;
|
s.players[0]!.revenue = 0;
|
||||||
advance(s);
|
advance(s);
|
||||||
assert.equal(s.status, 'finished');
|
assert.equal(s.status, 'awaitingExtension');
|
||||||
assert.equal(s.outcome!.result, 'win');
|
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[0]!.revenue = 5; // best individual score...
|
||||||
s.players[1]!.revenue = 3; // ...but combined (8) still misses the floor (20).
|
s.players[1]!.revenue = 3; // ...but combined (8) still misses the floor (20).
|
||||||
advance(s);
|
advance(s);
|
||||||
assert.equal(s.status, 'finished');
|
assert.equal(s.status, 'awaitingExtension');
|
||||||
assert.equal(s.outcome!.result, 'loss');
|
assert.equal(s.outcome!.result, 'loss');
|
||||||
assert.equal(s.outcome!.reason, 'revenueFloor');
|
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[0]!.revenue = 4;
|
||||||
s.players[1]!.revenue = 6; // combined 10 clears the floor, neither alone would.
|
s.players[1]!.revenue = 6; // combined 10 clears the floor, neither alone would.
|
||||||
advance(s);
|
advance(s);
|
||||||
assert.equal(s.status, 'finished');
|
assert.equal(s.status, 'awaitingExtension');
|
||||||
assert.equal(s.outcome!.result, 'win');
|
assert.equal(s.outcome!.result, 'win');
|
||||||
assert.equal(s.outcome!.winner, null, 'Co-op names an individual winner instead of a shared one');
|
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. ` +
|
`${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.',
|
'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');
|
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', () => {
|
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
|
// 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.
|
// 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');
|
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`);
|
assert.ok(turns > 50, `only ${turns} decisions — the game stalled`);
|
||||||
|
|
||||||
const f = session.view();
|
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.status, view(game).status);
|
||||||
assert.equal(f.day, view(game).day);
|
assert.equal(f.day, view(game).day);
|
||||||
assert.deepEqual(f.cells, view(game).cells, 'the board differs across the boundary');
|
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 { cardDescription, cardName, describeIntent, variantLabel } from '../src/sim/view.ts';
|
||||||
import { variantsFor } from '../src/engine/track.ts';
|
import { variantsFor } from '../src/engine/track.ts';
|
||||||
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
|
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
|
||||||
import { ENHANCEMENT_RULES } from '../src/engine/content.ts';
|
import { ENHANCEMENT_RULES, STAGES_PER_DAY } from '../src/engine/content.ts';
|
||||||
import { dayEndHtml, facilitiesHtml, timetableHtml } from '../src/web/panels.ts';
|
import { dayEndHtml, facilitiesHtml, resultsHtml, timetableHtml } from '../src/web/panels.ts';
|
||||||
import { turnChartHtml } from '../src/sim/turnchart.ts';
|
import { turnChartHtml } from '../src/sim/turnchart.ts';
|
||||||
import { fieldSelectors } from '../src/web/settings-form.ts';
|
import { fieldSelectors } from '../src/web/settings-form.ts';
|
||||||
import { record, renderHtml } from '../src/sim/replay.ts';
|
import { record, renderHtml } from '../src/sim/replay.ts';
|
||||||
import type { Frame } from '../src/sim/view.ts';
|
import type { Frame } from '../src/sim/view.ts';
|
||||||
import { snapshot } from '../src/sim/view.ts';
|
import { snapshot } from '../src/sim/view.ts';
|
||||||
import { createGame as createEngineGame } from '../src/engine/setup.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 {
|
import {
|
||||||
overHandLimit,
|
overHandLimit,
|
||||||
actionGroups,
|
actionGroups,
|
||||||
@@ -104,6 +106,18 @@ describe('the browser game plays', () => {
|
|||||||
it('reaches the end of a game through the same calls the page makes', () => {
|
it('reaches the end of a game through the same calls the page makes', () => {
|
||||||
const { game, turns } = playThrough(77);
|
const { game, turns } = playThrough(77);
|
||||||
assert.ok(turns > 50, `only ${turns} decisions — the game stalled`);
|
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.equal(game.state.status, 'finished');
|
||||||
assert.ok(game.state.outcome !== null, 'a finished game must have an outcome');
|
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 asked = new Set([...src.matchAll(/\$\('([a-zA-Z][\w-]*)'\)/g)].map((m) => m[1]!));
|
||||||
const html = readFileSync(join(dist, 'play.html'), 'utf8');
|
const html = readFileSync(join(dist, 'play.html'), 'utf8');
|
||||||
const present = new Set([...html.matchAll(/id="([a-zA-Z][\w-]*)"/g)].map((m) => m[1]!));
|
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.
|
// Created by the ending screen (`renderEnding`) before they are looked up, so they are never in
|
||||||
present.add('again');
|
// 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) {
|
for (const id of asked) {
|
||||||
assert.ok(present.has(id), `main.ts asks for #${id}, which the page does not contain`);
|
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', () => {
|
it('gives the page the dialog to fill', () => {
|
||||||
const html = readFileSync(join(dist, 'play.html'), 'utf8');
|
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}`);
|
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)', () => {
|
describe('every file the build needs is actually in the repo (regression)', () => {
|
||||||
it('does not gitignore a source page', () => {
|
it('does not gitignore a source page', () => {
|
||||||
// REGRESSION. `.gitignore` carried `replay*.html` to catch the throwaway files generated at the
|
// REGRESSION. `.gitignore` carried `replay*.html` to catch the throwaway files generated at the
|
||||||
|
|||||||
Reference in New Issue
Block a user