v0.4.1 - bug fixes. initial d12 rolls determine what player in which seat.

This commit is contained in:
Jesse
2026-08-13 15:28:53 -04:00
parent 49f8504b05
commit a7221dcf20
22 changed files with 736 additions and 237 deletions
+118
View File
@@ -21,6 +21,124 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
## Unreleased ## Unreleased
## 0.4.1 — 2026-08-13
### §4.4's opening D12 now decides who sits where
It had been rolled and thrown away — `void divisionRolls`, with seating fixed by array order — so the
rule decided nothing, and `seating` was the identity mapping in every game ever played. `seating[seat]
= player` now runs ascending by roll from seat 0 (west, beside the Western Division Point) to the last
seat: "highest is the Eastern Division Point".
The rule names only those two ends, because at a table the players are already sitting in a chain and
the roll only says which way round it is. There is no physical table here, so the roll orders
everybody — it uses a number every player is already told to roll, and it makes the roll matter to
more than the winner. Ties break toward the lower player index sitting further east, the same
first-max-wins convention `argmax` already uses for the Superintendent roll.
**Turning it on immediately found three more of last commit's bug class.** Acting order, the opening
deal and the Fedora all did `(player + n) % players`. Every one of them is a statement about the
physical chain — "starting from the Superintendent and proceeding left" (Gap 1, §4.7, §5) — so every
one of them is seat arithmetic, and all three were right only while seating was the identity mapping.
They now go through a new `playerLeftOf(state, player, n)`. This is the payoff of the split being
exercised by real games rather than only by tests that rotate `seating` by hand: eight of these were
found by inspection last commit, and three more fell out of simply making the rule work.
`state.openingRolls` keeps both D12s, indexed by player, so a lobby can show the chain forming rather
than only its result (`lobby-and-sessions.md` §4). `Frame.players` gained `seat` for the same reason —
the list is in player order because it is about people, and a client that wants to draw the table
west-to-east now can.
**Solitaire is untouched, and the proof is that it had better be:** one player is one seat, so the
permutation is trivially `[0]`. All four published replays finish on their recorded Revenue (27, 31,
29, 28) and the bot is unmoved at 7.0 mean over 200 games.
Nine multiplayer tests failed on the change and every one of them was the test being wrong — each had
encoded the identity mapping, which is exactly what made them pass before. `readyToLeave` was putting
a *player* index into a tray's `seat` field; the Subdivision tests upgraded "player 1's Office" when a
Subdivision is a stretch of the physical chain; the Fedora test recorded player indices where §5 is
about seats. Two new tests replace the one that asserted the identity mapping outright: seating is a
permutation (and is still `[0]` in solitaire), and over 40 seeds the highest roller is easternmost with
the chain ordered throughout.
### The intents are canonical; the event log narrates
The README, four architecture documents and six source comments all claimed `state = fold(events)`.
It was never true, and it was load-bearing — the stated justification for reconnection, restart
recovery and persistence, none of which were built yet, so nothing had ever tested the claim.
Measured before deciding: **`advance.ts` never calls `reduce`.** Fourteen of the forty-six event types
are emitted after the phase driver has already mutated state — the clock, and the whole Mainline
phase. Folding the log rebuilds a district and not a railroad; every train movement in the game is
missing.
**Settled the cheap way, because the plan never needed fold.** Persistence is
`{ engineVersion, seed, config, history: Intent[] }` (`multiplayer.md` §10), the wire carries `Frame`s
rather than events (D2/D3), and reconnection is a fresh `Frame` rather than an event tail — so making
the phase driver reduce would have been a rewrite of the most rule-dense code in the project to buy
something nothing uses. `lobby-and-sessions.md` §6 previously said "persist the event log"; it now
persists the intents, which are smaller still and which `fromSave` already replays.
`test/events.test.ts` pins the unreduced set as a deliberate change-detector: shrink it and the test
tells you which documents now understate the engine; grow it and you have added another event on the
mutate-then-describe path. It also asserts the property that *does* hold — that replaying the intents
reproduces the board, the score, the clock and the crews exactly — and that `Save` still carries
nothing but a seed and a history.
### `lobby-and-sessions.md` reviewed, and four decisions taken
The persistence rewrite above left the document internally consistent but unreviewed — it was written
before `multiplayer.md` and contradicted it in four places, and the code in two more.
*Contradictions with the newer plan:* it had **no access control at all** ("a game code is the whole
discovery mechanism") where D14 added a server-wide join secret; its timeout table used the fictional
`Switch.End`-style names and **omitted `freightAgent.end`**, so a player who chose Freight Agent never
timed out; it opened "one actor at a time", which D19 stopped being true; and "do not substitute an AI
player" contradicted D8's bots-at-lobby-time.
*Contradictions with the code:* §4 described the opening D12 for the Eastern Division Point as
happening, when `setup.ts:266` rolls it and does `void divisionRolls` — seating is array order, so the
rule decides nothing. `PlayerDisconnected` was written as an event; there is no such type, and it
should stay out of `GameEvent`, which must remain replayable from a seed.
**Four decisions:**
- **The session token names the PLAYER, not the seat.** `multiplayer.md` §10 said seat; that became
wrong in v0.4.0, and Employee Rotation is exactly the case where it bites — a token naming a chair
seats a returning player in someone else's Office. Both documents now agree, and say the seat is
`seatOf(state, player)`.
- **2 to 4 players**, enforced in the lobby because the engine enforces nothing. Set to what is
actually exercised rather than to the previous guess of 6. Noted as a lobby judgment, not a rules
limit — the rules describe 5+ and the engine implements it.
- **No forcing turn timer.** Cut, on the same objection that keeps bots out of running games: the
clearance decision changes somebody else's score, so anything answering it automatically changes
the game. Moved to `TODO.md` to explore only if halted games prove to be a real problem, keeping
the one piece of reasoning worth saving — that **deny** is the safe default, since a held train
costs a Stage and a wrecked one costs 5 Revenue and feeds the collision floor.
- **A turn clock that records rather than enforces.** Wall-clock per player per phase, so "how long
does a 4-player game take, and which phase is the wait?" becomes measured instead of guessed — the
one measurement bot simulation cannot produce, because the bot does not think. Explicitly outside
the rules engine (which has no clock and must not acquire one) and outside the canonical record
(a replay must reproduce a game from decisions alone). Phase 3, item 16.
- Plus: **host rights pass to the earliest-joined remaining player** if the host leaves before start,
so no lobby is stuck behind a closed tab.
### Documentation reconciled with the code
`docs/design.md`'s status section was four milestones stale: a 115-card deck (it is 213 in play, from
a 235-card catalogue), 134 tests (497), a 0% win rate and 0.9 Revenue/Day (7.0 mean, 1.4/Day, 5 wins
in 200), and "Next: step 7 begins the server" when Phase 2 is deliberately held. Rewritten as the
shape of the project rather than a running tally, pointing at the CHANGELOG and `TODO.md` for
anything that moves — a hand-maintained tally is exactly what drifted.
The README also said the rules were **fully specified**, which overstates it: three questions are
genuinely open — where the Local's coach stands while its engine works (a §A.4 question rather than a
train-card one), Poling, the one card in the deck with no defined behaviour, and whether a Heavy
Grade's orientation is rolled or chosen at setup. Thirteen prototype gaps were closed; these three
came after, and the bullet now says so and names them.
## 0.4.0 — 2026-08-13 ## 0.4.0 — 2026-08-13
### Phases 0 and 1 of the multiplayer plan — the seams, not the server ### Phases 0 and 1 of the multiplayer plan — the seams, not the server
+12 -5
View File
@@ -10,12 +10,15 @@ train into an occupied Subdivision. Get that wrong and two trains meet at speed.
## Status ## Status
**v0.4.0 — solitaire is playable in a browser.** The whole game runs client-side: the engine is pure, **v0.4.1 — solitaire is playable in a browser.** The whole game runs client-side: the engine is pure,
imports nothing outside itself, and never touches `Math.random`, so a static host is all it needs. imports nothing outside itself, and never touches `Math.random`, so a static host is all it needs.
- **Rules** — fully specified. Ten gaps in the original prototype rules found and resolved. - **Rules** — specified, with **three open questions** left. Thirteen gaps in the original prototype
rules were found and closed; three more came after, and are in `TODO.md`: where the Local's coach
stands while its engine works (a §A.4 question), Poling — the one card in the deck with no defined
behaviour — and whether a Heavy Grade's orientation is rolled or chosen at setup.
- **Card faces** — every card's printed values specified. - **Card faces** — every card's printed values specified.
- **Architecture** — six documents, including a 20-component build plan. - **Architecture** — seven documents, including a 20-component build plan and the multiplayer plan.
- **Code** — the engine, the bot, the balance harness, the replay viewer and the playable page. A - **Code** — the engine, the bot, the balance harness, the replay viewer and the playable page. A
game can be saved, shared, replayed and stepped back through. game can be saved, shared, replayed and stepped back through.
- **Not built** — the multiplayer server (Phases 0 and 1 of the plan are done: seat and player are - **Not built** — the multiplayer server (Phases 0 and 1 of the plan are done: seat and player are
@@ -94,8 +97,12 @@ is the thing this machinery exists to prevent.
- **It has two entry points, not one.** `apply(state, intent)` for player actions, and - **It has two entry points, not one.** `apply(state, intent)` for player actions, and
`advance(state)` for everything the game does on its own — Mainline movement, collisions, the Stage `advance(state)` for everything the game does on its own — Mainline movement, collisions, the Stage
clock, Superintendent rotation. clock, Superintendent rotation.
- **State is `fold(events)`.** The event log is the source of truth, which is what gives reconnection, - **The canonical record is the seed plus the intents.** A game is `{ seed, history: Intent[] }` and
restart recovery and post-game replay from a single decision. `fromSave` replays it exactly — that one property gives save, share, undo, restart recovery and
post-game replay. Events are a DERIVED stream: they narrate what happened and drive the display,
and they do not reconstruct the position. The phase driver mutates state and then describes it, so
fourteen of the forty-six event types are never reduced. Anything that needs to rebuild a game
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.
- **Track is a deck card, but the opening district is dealt.** 96 of the 235 cards are track — the - **Track is a deck card, but the opening district is dealt.** 96 of the 235 cards are track — the
largest category — so a district is built from what you draw, and building it costs you the largest category — so a district is built from what you draw, and building it costs you the
+23 -7
View File
@@ -127,13 +127,18 @@ Ordered within each section by how much it is currently costing us.
can be tuned until this is settled**. The fix is a decision, not a patch: either a load stops can be tuned until this is settled**. The fix is a decision, not a patch: either a load stops
being a `RollingStock` and becomes its own type, or `inboundCleared` discards rather than being a `RollingStock` and becomes its own type, or `inboundCleared` discards rather than
banking. Found by a conservation audit, not by a failing test. banking. Found by a conservation audit, not by a failing test.
- [ ] **`state = fold(events)` is not literally true, and the README says it is.** Replaying the - [x] **`state = fold(events)` was not true, and the docs said it was. Settled: the INTENTS are
event log onto a fresh state throws: the phase driver mutates state directly and emits a canonical.** Measured before deciding — `advance.ts` never calls `reduce`, so **14 of the 46
descriptive event afterwards — `newTrainPhase` does `s.trays.set(...)` and then pushes event types are never reduced**: the clock, and the entire Mainline phase, which is every train
`trainMadeUp`. Replay works because it re-applies INTENTS (`fromSave`), not because folding movement in the game. Folding the log rebuilds a district and not a railroad. Jesse's call, and
events reconstructs the position. Nothing is broken today, but the claim underwrites the cheap one: the plan never needed fold — persistence is `{ engineVersion, seed, config,
reconnection and restart recovery, which are unbuilt — so it should be either made true or history }` (`multiplayer.md` §10) and the wire carries `Frame`s, not events (D2/D3), so
restated before anything is built on it. reconnection is a fresh Frame rather than an event tail. Making the phase driver reduce would
have been a rewrite of the most rule-dense code in the project to buy something nothing uses.
Corrected in the README, four architecture documents and six source comments; `test/events.test.ts`
pins the unreduced set so that closing the gap later is a deliberate act, and asserts the
property that does hold. **If you ever do make the phase driver reduce, that test fails and
tells you which docs now understate the engine.**
- [ ] **Engines are not a SUPPLY yet, only a position.** `engineAt` now records where the engine - [ ] **Engines are not a SUPPLY yet, only a position.** `engineAt` now records where the engine
sits in the tray and the consist shows it, but an engine is still conjured with the tray sits in the tray and the consist shows it, but an engine is still conjured with the tray
rather than drawn from the Division Yard and returned to it. The rules put engines in the rather than drawn from the Division Yard and returned to it. The rules put engines in the
@@ -477,6 +482,17 @@ target is settled and freight carries its intended share.
another player's score. Bots fill empty seats at lobby time only (D8). another player's score. Bots fill empty seats at lobby time only (D8).
- **Let a player resign and hand their railroad to a bot** to finish. Same care needed as - **Let a player resign and hand their railroad to a bot** to finish. Same care needed as
above, but it is consented rather than imposed. above, but it is consented rather than imposed.
- **A forcing turn timer — explicitly NOT in the design.** `lobby-and-sessions.md` §5 used to
specify one: on expiry the server took "the safest legal action", including denying a
clearance. Cut in the review, because it is the same objection as a bot playing for an absent
player — the clearance decision changes somebody else's score, so anything that answers it
automatically changes the game. Explore later if halted games turn out to be a real problem
at a real table; the reasoning worth keeping is that **deny** is the safe default, since a
held train costs a Stage and a wrecked one costs 5 Revenue and feeds the collision floor.
- **~~The opening D12 for the Eastern Division Point (§4.4) decides nothing.~~ Done in
v0.4.1** — it orders the whole chain now, west to east by ascending roll. The lobby still owes
it a display: `state.openingRolls` is kept so clients can show the rolls forming the chain
rather than only the result (`lobby-and-sessions.md` §4).
- **Revisit the join secret** (D14). One server-wide secret, passed out of band, gates create - **Revisit the join secret** (D14). One server-wide secret, passed out of band, gates create
and join. Enough for a private box, probably not enough if `stationmaster.<domain>` is and join. Enough for a private box, probably not enough if `stationmaster.<domain>` is
pointed at the open internet for long. Note that one-game-at-a-time per person is expected pointed at the open internet for long. Note that one-game-at-a-time per person is expected
+11 -10
View File
@@ -35,7 +35,7 @@ decision (you are the Superintendent, ruling on your own trains).
| Turn arbitration between players | Card catalogue | | Turn arbitration between players | Card catalogue |
| Presence, reconnection of others | Track graph and Move legality | | Presence, reconnection of others | Track graph and Move legality |
| Fedora rotation between players | Board and action UI | | Fedora rotation between players | Board and action UI |
| Competitive/Co-op victory modes | Event log and persistence | | Competitive/Co-op victory modes | Intent history and persistence |
| Collision floor (Competitive only) | Server and protocol | | Collision floor (Competitive only) | Server and protocol |
**Consequence: the engine is the dominant cost and it is front-loaded.** Components 4–6 barely differ **Consequence: the engine is the dominant cost and it is front-loaded.** Components 4–6 barely differ
@@ -141,11 +141,12 @@ is what makes the whole system testable without a server.
- **Size driver:** at MVP one game and one player, so the pump loop is nearly all of it. Grows with - **Size driver:** at MVP one game and one player, so the pump loop is nearly all of it. Grows with
concurrent games and per-player routing. concurrent games and per-player routing.
**9. Event log and persistence** **9. Intent history and persistence**
- **Where:** Server · **When:** append per event; full read on startup - **Where:** Server · **When:** append per event; full read on startup
- **Does:** append-only log to disk; state is `fold(events)`. Powers restart recovery at MVP, and - **Does:** append-only `{ engineVersion, seed, config, history: Intent[] }` to disk. Restart recovery
later reconnection and the replay viewer. is `fromSave` over the stored intents; the replay viewer reads the same file
([`protocol.md`](protocol.md) §3 — events narrate, intents reconstruct).
- **MVP: S** · **Final: M** · ~100 → ~350 LOC - **MVP: S** · **Final: M** · ~100 → ~350 LOC
- **Size driver:** an append and a read-back. Grows with snapshotting, retention and indexing. - **Size driver:** an append and a read-back. Grows with snapshotting, retention and indexing.
@@ -180,7 +181,7 @@ is what makes the whole system testable without a server.
- **Where:** Server · **When:** once, at process start - **Where:** Server · **When:** once, at process start
- **Does:** reads environment configuration (bind address, port, data directory), opens or creates the - **Does:** reads environment configuration (bind address, port, data directory), opens or creates the
event log, constructs the session host and transport, wires them together, handles shutdown. intent history, constructs the session host and transport, wires them together, handles shutdown.
- **MVP: XS** · **Final: S** · ~80 → ~200 LOC - **MVP: XS** · **Final: S** · ~80 → ~200 LOC
- **Size driver:** trivial in itself, but it is where - **Size driver:** trivial in itself, but it is where
[`deployment.md`](deployment.md)'s five portability rules are actually enforced — single process [`deployment.md`](deployment.md)'s five portability rules are actually enforced — single process
@@ -223,9 +224,9 @@ is what makes the whole system testable without a server.
**16. Replay viewer** **16. Replay viewer**
- **Where:** Browser · **When:** after a game ends — **pulled forward, built** - **Where:** Browser · **When:** after a game ends — **pulled forward, built**
- **Does:** read-only projection over the event log, with playback controls. A Node script - **Does:** read-only playback of a stored game, with controls. A Node script replays the saved
precomputes one frame per visible event and writes a self-contained HTML file, so the browser intents, precomputes one `Frame` per visible event with its narration, and writes a self-contained
never runs the engine and there is no bundling or build step. HTML file — so the browser never runs the engine and there is no bundling or build step.
- **MVP: —** · **Final: S** · 0 → ~250 LOC · *built: 560 LOC (replay) + 330 (narration)* - **MVP: —** · **Final: S** · 0 → ~250 LOC · *built: 560 LOC (replay) + 330 (narration)*
- **Size driver:** the narration layer, not the rendering. Turning 30 event types into readable - **Size driver:** the narration layer, not the rendering. Turning 30 event types into readable
sentences and deriving the "what is currently blocked" panel is most of it. sentences and deriving the "what is currently blocked" panel is most of it.
@@ -304,7 +305,7 @@ under.
[4]apply [5]advance [6]legalActions [4]apply [5]advance [6]legalActions
└───────┼───────┘ └───────┼───────┘
▼ ▼
[8] session host ──► [9] event log [8] session host ──► [9] intent history
│ │
▼ ▼
[10] view projection [10] view projection
@@ -393,7 +394,7 @@ Crew Tray onto a card holding two standing cars, which couples them automaticall
| 4 | Apply | Validates the Move via component 3; rejects with `WOULD_REVERSE` / `NOT_OPERATIONAL_RAIL` / `TRACK_OCCUPIED` if illegal. | | 4 | Apply | Validates the Move via component 3; rejects with `WOULD_REVERSE` / `NOT_OPERATIONAL_RAIL` / `TRACK_OCCUPIED` if illegal. |
| 3 | Track graph | Confirms the path exists without a direction change. | | 3 | Track graph | Confirms the path exists without a direction change. |
| 4 | Apply | Moves the tray, couples the standing cars **in track order**, checks the four-slot limit, decrements Moves. Emits `TrayMoved` and `CarsCoupled`. | | 4 | Apply | Moves the tray, couples the standing cars **in track order**, checks the four-slot limit, decrements Moves. Emits `TrayMoved` and `CarsCoupled`. |
| 9 | Event log | Appends both events to disk. | | 9 | Intent history | Appends the accepted `switch.move` intent to the game's history on disk. |
| 8 | Session host | Calls `advance` — no automatic work is due mid-Local-Ops, so it returns `needsInput`. | | 8 | Session host | Calls `advance` — no automatic work is due mid-Local-Ops, so it returns `needsInput`. |
| 10 | View projection | Rebuilds the view. Nothing is redacted here; the board is public. | | 10 | View projection | Rebuilds the view. Nothing is redacted here; the board is public. |
| 6 | Legal-action enumeration | Recomputes affordances from the new state — fewer Moves remain, and a fuller consist may now be at its four-slot limit. | | 6 | Legal-action enumeration | Recomputes affordances from the new state — fewer Moves remain, and a fuller consist may now be at its four-slot limit. |
+1 -1
View File
@@ -16,7 +16,7 @@ The requirements are modest, which is what makes both paths viable:
| --- | --- | | --- | --- |
| One long-running process | Active games are held in memory ([`overview.md`](overview.md)) | | One long-running process | Active games are held in memory ([`overview.md`](overview.md)) |
| Plain HTTP on one port | Lobby and intents over POST, game stream over SSE — no WebSocket upgrade, which is what keeps this a *plain* HTTP need ([`multiplayer.md`](multiplayer.md) D5) | | Plain HTTP on one port | Lobby and intents over POST, game stream over SSE — no WebSocket upgrade, which is what keeps this a *plain* HTTP need ([`multiplayer.md`](multiplayer.md) D5) |
| A writable data directory | The append-only event log ([`lobby-and-sessions.md`](lobby-and-sessions.md)) | | A writable data directory | The append-only `{ seed, config, history }` per game ([`lobby-and-sessions.md`](lobby-and-sessions.md)) |
| Static asset serving | The browser client | | Static asset serving | The browser client |
| No outbound network access | The game talks to nobody | | No outbound network access | The game talks to nobody |
| No scheduled work | Nothing in the rules is real-time ([`overview.md`](overview.md)) | | No scheduled work | Nothing in the rules is real-time ([`overview.md`](overview.md)) |
+152 -61
View File
@@ -10,20 +10,31 @@ Written against [`../rules/rules-v0.2.md`](../rules/rules-v0.2.md).
## 1. Identity ## 1. Identity
No accounts to begin with. A player is a **display name** plus a **session token** the server issues No accounts. Access to the server is a **join secret** (§2); identity within a game is a **display
on join and the client stores locally. name** plus a **session token** the server issues on join and the client stores locally.
``` ```
Session Session
token : opaque, unguessable token : opaque, unguessable
gameId gameId
playerIndex player : PlayerIndex
displayName displayName
``` ```
The token is what makes reconnection work: it proves "I am the player who was sitting at seat 2," **It names the PLAYER, not the seat.** Those became different things in v0.4.0, and the difference is
which is the only identity claim the game needs. Keep it out of URLs so it is not shoulder-surfed or exactly the case this field has to survive: under Employee Rotation (§4) a player changes chairs while
pasted into a chat. their Revenue and their identity stay with them. A token naming a seat would sit a returning player
down in someone else's Office. The seat is derived with `seatOf(state, player)` whenever it is needed,
which is one lookup and always current.
The token is what makes reconnection work: it proves "I am the player who was in this game," which is
the only identity claim the game needs. Keep it out of URLs so it is not shoulder-surfed or pasted
into a chat.
**Tokens are per-origin.** The server may be reached by more than one address — `stationmaster.<domain>`
and `<ip>:<port>` are both expected — and browser storage is scoped to the origin. A player who joined
at one address must come back to that address, or they are a stranger with no token. Say so in the
UI at join time rather than letting someone discover it when they cannot get back in.
Real accounts can be layered on later without touching the rules engine, which is exactly why Real accounts can be layered on later without touching the rules engine, which is exactly why
[`overview.md`](overview.md) keeps that boundary sharp. [`overview.md`](overview.md) keeps that boundary sharp.
@@ -33,22 +44,45 @@ Real accounts can be layered on later without touching the rules engine, which i
## 2. Creating and joining ## 2. Creating and joining
``` ```
Lobby.Create { config } → { gameId, gameCode, token } Lobby.Create { secret, config } → { gameId, gameCode, token }
Lobby.Join { gameCode, displayName } → { token, playerIndex } Lobby.Join { secret, gameCode, displayName } → { token, player }
``` ```
A **game code** — short, human-speakable, e.g. `RAIL-4471` — is the whole discovery mechanism. No **These four messages are the one family with no types behind them**, because the engine has no
matchmaking, no browsing, no public game list. Players are already talking to each other; the code concept of a lobby — `intents.ts` starts at `localOps.choose`. Everywhere else, the code is the
just needs to survive being read aloud. protocol ([`protocol.md`](protocol.md)); here the prose is, until the server exists.
The creating player is the **host**: they set the config (§3) and start the game. Host-only rights **The join secret gates the door.** Server-wide, set by environment variable, passed out of band by
end at `Lobby.Start` — the rules give no player special standing during play, and the Superintendent whoever runs the server (`multiplayer.md` D14). Anyone holding it may create a game and may join any
role rotates independently (§5). created game that has not started. It is not per-game and it is not an account — it is the cheapest
thing that stops a clearnet-reachable box from being someone else's game server. Typed once and kept
per-origin, alongside the token.
**Player counts.** Solitaire is 1. Competitive and Co-op need at least 2. The upper bound is a A **game code** — short, human-speakable, e.g. `RAIL-4471` — is the discovery mechanism *inside* the
practical judgment rather than a rules limit: the Division grows by one Office and one Mainline card door. No matchmaking, no browsing, no public game list. Players are already talking to each other; the
per player (§4.3), and every added player lengthens the Superintendent's rotation and the code just needs to survive being read aloud.
phase-major waiting. **6 is a sensible cap** for the prototype.
**One game at a time per person is expected usage and is deliberately not enforced** (`multiplayer.md`
§10). Enforcing it needs cross-game state whose only job is deciding when to release someone, and
getting that wrong locks a player out of their own server.
The creating player is the **host**: they set the config (§3) and start the game. **If the host leaves
before `Lobby.Start`, host rights pass to the earliest-joined remaining player.** No lobby should ever
be stuck waiting on somebody who closed a tab, and the alternative — everyone leaves and someone makes
a new game — throws away the seating they had already agreed. Host rights end at `Lobby.Start`
regardless: the rules give no player special standing during play, and the Superintendent rotates
independently (§4).
**Player counts.** Solitaire is 1. Competitive and Co-op are **2 to 4**. Nothing in the engine enforces
a limit, so the lobby is where it is enforced — and the number is set to what is actually exercised
(`test/multiplayer.test.ts` plays 2, 3 and 4 to a finish) rather than to a guess. The cost of more is
real: the Division grows by one Office and one Mainline card per player (§4.3), and every added player
lengthens both the Superintendent's rotation and the phase-major waiting. Raise the cap when somebody
has played a bigger game and reported back, not before.
This is a lobby judgment, **not a rules limit** — §7 of the rules describes what happens at five or
more players (the New Train round ends mid-pass when the consist fills) and the engine implements it.
The cap is about what has been played, not about what the game can do.
--- ---
@@ -67,6 +101,10 @@ These must lock at `Lobby.Start`. Changing `length` mid-game would move the fini
`mode` would switch which failure floors apply (§3.4, §3.5). Neither has a coherent meaning `mode` would switch which failure floors apply (§3.4, §3.5). Neither has a coherent meaning
mid-game, so the server should refuse rather than try. mid-game, so the server should refuse rather than try.
`mode` and the seat count are checked together at start: solitaire is exactly 1, competitive and
co-op are 2 to 4 (§2). The engine will happily build a 7-player Division, so this is the only place
the limit exists.
--- ---
## 4. Seating ## 4. Seating
@@ -82,80 +120,132 @@ So seating must be settled before the opening D12 rolls, and the lobby should sh
visually — a player should see whose Office lies east and west of theirs before the game begins. visually — a player should see whose Office lies east and west of theirs before the game begins.
**The opening rolls** (§4.4, §4.5) happen server-side at `Lobby.Start`, from the game seed: highest **The opening rolls** (§4.4, §4.5) happen server-side at `Lobby.Start`, from the game seed: highest
D12 takes the Eastern Division Point, and a second roll picks the opening Superintendent. Emit both D12 takes the Eastern Division Point, and a second roll picks the opening Superintendent.
as events so clients can show the rolls rather than just the outcome — it is the game's first moment
of drama and there is no reason to hide it. **Both are implemented.** The Division roll was drawn and discarded until v0.4.1 — `void
divisionRolls`, with seating fixed by array order — so §4.4 decided nothing and the seat/player split
was untested at runtime. It orders the chain now: `seating[seat] = player`, ascending by roll from
seat 0 (west, beside the Western Division Point) to the last seat (east, "highest is the Eastern
Division Point").
The rule names only those two ends, because at a table the players are already sitting in a chain and
the roll only says which way round it is. There is no physical table here, so the roll orders
everybody — it uses a number every player is already told to roll, and it makes the roll matter to
more than the winner. Ties break toward the lower player index sitting further east, the same
first-max-wins convention the Superintendent roll uses.
Both rolls are kept on the state as `openingRolls`, indexed by player, **so the lobby can show the
rolls rather than only their outcome** — it is the game's first moment of drama and there is no reason
to hide it. Show the chain forming.
**Solitaire is unchanged and must stay so:** one player is one seat, seating is trivially `[0]`, and
every published replay depends on that.
**Employee Rotation** (Appendix B), if enabled, moves every player one seat left at the end of each **Employee Rotation** (Appendix B), if enabled, moves every player one seat left at the end of each
Day, carrying their Revenue and the Fedora with them. Note what this means for the model: a player's Day, carrying their Revenue and the Fedora with them. **The model supports this as of v0.4.0**:
*seat* changes while their *score* follows them, so `Player.index` and Office ownership must be offices and districts are keyed by seat, hands and Revenue and the Fedora by player, and `seating[]`
separable. This is the one optional rule with real structural consequences — worth wiring in from the maps between them, so rotating is a one-line operation and every `areaOf(state, player)` caller
start rather than retrofitting. follows without changing. The *rule* is still unimplemented — nothing rotates `seating` yet — but the
structure it needs is in place and tested, which was the point of doing it early.
--- ---
## 5. Disconnection and reconnection ## 5. Disconnection and reconnection
Turn-based with one actor at a time makes this far easier than it would be in a real-time game. Being turn-based makes this far easier than it would be in a real-time game. (It is no longer "one
actor at a time" — v0.4.0 gave every player their own `TurnState` precisely so that players can act in
parallel where the rules allow it, `multiplayer.md` D19. Nothing about disconnection changes: the game
still waits on whoever it is waiting on.)
**On disconnect:** keep the seat. Do not remove the player, do not auto-play. The game simply waits **On disconnect:** keep the seat. Do not remove the player, do not auto-play. The game simply waits
if it was their turn. Broadcast a `PlayerDisconnected` event so everyone else can see why nothing is if it was their turn. Broadcast a disconnect notice so everyone else can see why nothing is happening
happening — silence with no explanation is the worst version of this. — silence with no explanation is the worst version of this. There is no such event type today; it is
server-layer news about a *connection*, not about the game, so it belongs with the transport rather
than in `GameEvent`, which must stay replayable from a seed.
**On reconnect:** the client presents its token, and the server replies with a full current view plus **On reconnect:** the client presents its token and the server replies with a **full current view**.
the event tail it missed. Because state is `fold(events)` ([`protocol.md`](protocol.md)), catching up Not an event tail — a returning client needs the position, not the history of how it got there, and
is a replay, not a special case. the server can always produce the position because it holds the game
([`protocol.md`](protocol.md) §3). This is why catching up is not a special case.
**On a player who does not come back:** the honest options at this scale are to wait, or to let the **On a player who does not come back:** the honest options at this scale are to wait, or to let the
host end the game. An optional **turn timer** is worth having — a lobby setting, off by default, host end the game.
never part of the rules — that on expiry takes the safest legal action:
| Phase | Timeout action | **Nothing moves on an absent player's behalf.** No forcing timer, no bot stepping in. Jesse's call,
| --- | --- | and the reasoning is the same one that keeps bots out of running games generally: the Superintendent's
| Local Operations | `Switch.End` / `Draw.End` — forfeit the remaining action | clearance decision materially affects *other* players' scores, so anything that answers it
| New Train | `NewTrain.PassCar` if legal, else place any legal car | automatically changes the game rather than preserving it. Bots fill empty seats **at game start
| Mainline clearance | **Deny** clearance — the safe answer; a held train costs a Stage, a wrecked one costs 5 Revenue and counts toward the collision floor | only** (`multiplayer.md` D8). Two ways out of a permanently-halted game are recorded in `TODO.md` and
| Load/Unload | `LoadUnload.End` | deliberately not designed here — a bot playing minimally-damaging defensive moves for someone who
stepped away, and a player *consenting* to resign their railroad to a bot. Both need care; neither is
a timer.
Denying clearance on timeout is the right default and worth stating explicitly: the asymmetry between ### The turn clock — a stopwatch, not a shot clock
the two outcomes is large, and in Competitive mode a timed-out clearance that causes a wreck could
end the game for everybody (§3.4).
**Do not substitute an AI player.** The Superintendent's clearance decisions materially affect other **Record how long each player takes on each turn.** Not to enforce anything: to find out where the
players' scores; a bot making them on an absent player's behalf changes the game rather than game actually goes. "A 4-player game takes an evening" is currently a guess, and the useful version of
preserving it. that answer is per-phase — whether the wait is Local Operations, or making up a train, or one player
thinking about a clearance while three others watch. That is what tells you which part is worth
speeding up, and it is the one measurement no amount of bot simulation can produce, because the bot
does not think.
Where it lives matters:
- **Outside the rules engine.** The engine has no clock and must not acquire one — it is pure and
deterministic given a seed, which is what makes every replay and every test work.
- **Outside the canonical record.** Timings are stored beside `{ engineVersion, seed, config, history }`,
never inside it. A replay must reproduce a game from decisions alone; if it depended on wall-clock
data it would no longer be reproducible from a seed.
- **In the session host**, which already sees each intent arrive and already knows whose turn it is.
Wall-clock at the start of a turn, wall-clock at the intent that ends it, per player per phase.
Show the current turn's elapsed time in the UI if it is unobtrusive — a table can self-regulate on
information alone, which is the polite version of a shot clock and costs nothing.
--- ---
## 6. Persistence ## 6. Persistence
Persist the **event log**, not a state snapshot. It is smaller, it is the thing that already exists, Persist the **intents**, not a state snapshot and not the event log. This section said "event log"
and it makes a mid-game server restart a replay rather than a recovery. until v0.4.0, on the strength of a `state = fold(events)` claim that was never true
([`protocol.md`](protocol.md) §3) — folding the log rebuilds card plays and switching, and not one
train movement. The intents genuinely do reconstruct a game, they are smaller still, and `fromSave`
already replays them, so a mid-game server restart is a replay rather than a recovery.
``` ```
games : gameId → { config, seed, status, createdAt, gameCode } games : gameId → { engineVersion, config, seed, status, createdAt, gameCode }
events : gameId → ordered event list history : gameId → ordered Intent list
sessions : token → { gameId, playerIndex, displayName } sessions : token → { gameId, player, displayName }
``` ```
`engineVersion` is stored beside the seed because a replay is only faithful under the rules it was
recorded with. A version mismatch on load must be refused explicitly rather than replayed and quietly
diverged (`multiplayer.md` Phase 3).
Requirements are modest enough that the storage choice is genuinely open — SQLite on a single node Requirements are modest enough that the storage choice is genuinely open — SQLite on a single node
covers this comfortably, and so would flat files with an append-only log per game. The constraint covers this comfortably, and so would flat files with an append-only list per game. The constraint
worth honouring is that the **event log is append-only**: rewriting history breaks the one property worth honouring is that the **history is append-only**: rewriting it breaks the one property that
that makes replay trustworthy. makes replay trustworthy. (Undo is not an exception — it replays the history *without* the last
intent, and a server does not offer it at all.)
**Snapshotting** is an optimisation, not a requirement. If a Campaign game's log grows long enough **Snapshotting** is an optimisation, not a requirement. If a Campaign game's history grows long
that replay feels slow, periodically store a state snapshot with its `eventSeq` and replay forward enough that replay feels slow, periodically store a state snapshot with its intent count and replay
from there. Do not build this until it is needed. forward from there. Do not build this until it is needed. Measured today: a finished solitaire game
is ~350 intents and a few hundred bytes.
**Retention.** Finished games **keep their event log, seed and config**, because post-game replay is **Retention.** Finished games **keep their history, seed and config**, because post-game replay is a
a wanted capability (see [`overview.md`](overview.md#post-game-replay--a-desired-future-capability)) wanted capability (see [`overview.md`](overview.md#post-game-replay--a-desired-future-capability))
and the log is the only thing it needs. Do not delete finished games by default. and those three are the only things it needs — the replay viewer on the site already reads exactly
that file. Do not delete finished games by default.
Retention is an operator setting rather than an application decision — a self-hosted instance among Retention is an operator setting rather than an application decision — a self-hosted instance among
friends may as well keep everything, since a finished game's log is small. If a cap is wanted, expire friends may as well keep everything, since a finished game's log is small. If a cap is wanted, expire
by age or by total count and say so plainly in the UI, because a replay that silently stops existing by age or by total count and say so plainly in the UI, because a replay that silently stops existing
is worse than one that was never offered. is worse than one that was never offered.
**Turn timings** (§5) are stored per game beside the history, never inside it, and are retained on the
same terms — they are only interesting in aggregate, and only across enough games to see a pattern.
Session tokens are the exception: those can expire on a short window once their game has finished. Session tokens are the exception: those can expire on a short window once their game has finished.
--- ---
@@ -167,7 +257,8 @@ At small self-hosted scale these are not needed, and building them early costs m
- **Matchmaking or a public game browser.** The game code covers discovery. - **Matchmaking or a public game browser.** The game code covers discovery.
- **Ranking, ladders, persistent profiles.** §3's timed modes produce comparable scores, so a - **Ranking, ladders, persistent profiles.** §3's timed modes produce comparable scores, so a
high-score table would be a natural first addition — but it is a feature, not infrastructure. high-score table would be a natural first addition — but it is a feature, not infrastructure.
- **Spectators.** Straightforward to add later, since a spectator is just a view with no `private` - **Spectators.** Straightforward to add later: a spectator is a `Frame` with no hand and no ability
section and no ability to send intents — the same shape post-game replay needs. to send intents. `snapshot` already takes a viewing seat, so the only new thing is a viewer that
belongs to no seat — the same shape post-game replay already uses.
- **Horizontal scaling and cross-process coordination.** One process holds every active game - **Horizontal scaling and cross-process coordination.** One process holds every active game
in memory. in memory.
+39 -15
View File
@@ -31,10 +31,17 @@ follows is assembly.
## 2. What holds, and what moved ## 2. What holds, and what moved
`overview.md`'s core claims survived contact with the implementation: server authority, a pure `overview.md`'s core claims survived contact with the implementation: server authority, a pure
deterministic engine, two entry points (`applyIntent` / `pump`), `state = fold(events)`, events that deterministic engine, two entry points (`applyIntent` / `pump`), events that render standalone,
render standalone, per-player redacted views. per-player redacted views.
Three things moved: Four things moved:
**`state = fold(events)` is not true, and the intents are canonical instead.** `applyIntent` does go
through the reducer, but the phase driver does not — it mutates and then emits a descriptive event —
so fourteen of the forty-six event types are never reduced, including the clock and the whole Mainline
phase. This costs nothing, because the plan never needed it: persistence is `{ seed, history }` (§10)
and the wire carries `Frame`s rather than events (D2/D3). Reconnection is a fresh `Frame`, not an
event tail.
**The save format is already the wire format.** A game is `{ seed, history: Intent[] }` and `fromSave` **The save format is already the wire format.** A game is `{ seed, history: Intent[] }` and `fromSave`
reconstructs it exactly. That is what makes persistence nearly free (§7). reconstructs it exactly. That is what makes persistence nearly free (§7).
@@ -270,14 +277,24 @@ Two consequences:
runs the server. Anyone holding it may create a game, and may join any created game that has not runs the server. Anyone holding it may create a game, and may join any created game that has not
started. No accounts, no user database. *(Revisit — see `TODO.md`.)* started. No accounts, no user database. *(Revisit — see `TODO.md`.)*
**Identity: a session token scoped to one game**, exactly as `lobby-and-sessions.md` already **Seating is decided by §4.4's D12** as of v0.4.1, so `seating` is a real permutation rather than the
describes. Joining issues it; presenting it *is* the rejoin, because it already names the game and identity mapping. Everything "round the table" — acting order, the deal, the Fedora — is seat
the seat: arithmetic via `playerLeftOf`, and the three places that were doing it with player indices were found
and fixed by turning the roll on. That is the point: the seat/player split is now exercised by every
multi-player game instead of only by tests that rotate `seating` by hand.
**Identity: a session token scoped to one game**, exactly as `lobby-and-sessions.md` describes.
Joining issues it; presenting it *is* the rejoin, because it already names the game and the person:
``` ```
Session token, gameId, seat, displayName Session token, gameId, player, displayName
``` ```
It names the **player**, not the seat — the two stopped being the same thing in v0.4.0, and Employee
Rotation is precisely the case where a token naming a chair would seat someone in the wrong Office.
The seat is `seatOf(state, player)`, one lookup and always current. (This said `seat` until the
`lobby-and-sessions.md` review.)
The join secret is separate and server-wide — typed once and kept per-origin for convenience. It The join secret is separate and server-wide — typed once and kept per-origin for convenience. It
gates entry; the token identifies a seat. gates entry; the token identifies a seat.
@@ -378,31 +395,38 @@ source check that `main.ts` never regains a `GameState` read or a value import f
14. Append-only `{engineVersion, seed, config, history}` per game; index. 14. Append-only `{engineVersion, seed, config, history}` per game; index.
15. Load on start; rebuild via `fromSave`; refuse a version mismatch explicitly. 15. Load on start; rebuild via `fromSave`; refuse a version mismatch explicitly.
16. **Turn timings**, stored beside the history and never inside it — wall-clock per player per
phase, so "how long does a 4-player game take, and which phase is the wait?" becomes a measured
answer instead of a guess (`lobby-and-sessions.md` §5).
**Done when:** the server restarts mid-game and both clients carry on. **Done when:** the server restarts mid-game and both clients carry on.
### Phase 4 — Lobby, sessions, reconnection · M ### Phase 4 — Lobby, sessions, reconnection · M
16. Join secret; per-game session token; display name. 17. Join secret; per-game session token naming the **player** (not the seat); display name.
17. Create/join by game code; bot seats; seating UI showing the west-to-east chain; config locked at 18. Create/join by game code; bot seats; seating UI showing the west-to-east chain; config locked at
start. start; **2–4 players enforced here**, since the engine enforces nothing.
18. Disconnect keeps the seat and announces it; reconnect resumes from `Last-Event-ID`. 19. Disconnect keeps the seat and announces it; reconnect replies with a full `Frame`.
19. Optional turn timer, off by default, **denying** clearance on expiry. 20. Host rights pass to the earliest-joined remaining player if the host leaves before start.
**No forcing turn timer** — cut in the `lobby-and-sessions.md` review, and in `TODO.md` as something
to explore only if halted games turn out to be a real problem. Nothing moves on an absent player's
behalf.
**Done when:** four people join from four browsers at two different addresses, one closes the tab and **Done when:** four people join from four browsers at two different addresses, one closes the tab and
rejoins where they left off. rejoins where they left off.
### Phase 5 — Multiplayer content · M ### Phase 5 — Multiplayer content · M
20. The 10 Action and 12 Space-use cards; flip `opponentCardsInDeck` in `setup.ts`. 21. The 10 Action and 12 Space-use cards; flip `opponentCardsInDeck` in `setup.ts`.
21. Facing Point Locks, Water Column and Overpass stop being dormant — already wired. 22. Facing Point Locks, Water Column and Overpass stop being dormant — already wired.
**Done when:** an opponent-directed card resolves against another player and the Enhancement that **Done when:** an opponent-directed card resolves against another player and the Enhancement that
answers it fires. answers it fires.
### Phase 6 — Package for StartOS · S ### Phase 6 — Package for StartOS · S
22. `.s9pk` per the workspace guide: interface, health check, backup of the data directory. 23. `.s9pk` per the workspace guide: interface, health check, backup of the data directory.
**Done when:** it installs on a StartOS box and players on two different addresses play a game. **Done when:** it installs on a StartOS box and players on two different addresses play a game.
+18 -8
View File
@@ -67,12 +67,18 @@ Three flows, and keeping them distinct is what keeps the implementation tractabl
``` ```
- **Intents** are what a player wants to do. They are proposals; they can be rejected. - **Intents** are what a player wants to do. They are proposals; they can be rejected.
- **Events** are what happened. They are facts, ordered, and form the game's history. - **Events** are what happened. They are facts, ordered, and standalone — they narrate the game.
They do **not** reconstruct it: see `protocol.md` §3, and the note below.
- **Views** are per-player projections of state, with other players' hands redacted. - **Views** are per-player projections of state, with other players' hands redacted.
An event log that fully determines state is worth building even at this scale. It gives A recorded, ordered history is worth keeping even at this scale. It gives persistence (store the
reconnection (replay to catch up), persistence (store the log, not a snapshot), and debugging (replay history, not a snapshot) and debugging (replay a reported bug exactly) for one design decision.
a reported bug exactly) for one design decision.
**That history is the INTENTS, not the events.** This paragraph originally said an event log "fully
determines state", and it does not — the phase driver mutates state and then emits a descriptive
event, so fourteen of the forty-six event types are never reduced, including every train movement.
A game is `{ seed, history: Intent[] }` and `fromSave` replays it exactly. Reconnection is therefore a
fresh view rather than a catch-up replay, which is simpler anyway.
### Post-game replay — a desired future capability ### Post-game replay — a desired future capability
@@ -84,9 +90,13 @@ traffic, the wrecks, who was Superintendent when. For a game whose drama is larg
you are playing your own Office, this is worth more than it would be in most games. You spend the you are playing your own Office, this is worth more than it would be in most games. You spend the
game watching your own station; the replay is where you find out what the railroad was doing. game watching your own station; the replay is where you find out what the railroad was doing.
State is already `fold(events)` over an ordered, append-only log, and all randomness derives from a A game is already reconstructible from `{ seed, history: Intent[] }`, and all randomness derives from
stored seed. Replay is therefore a **read-only projection over the existing log** — no new rules-engine that stored seed. Replay is therefore a **read-only re-run of the stored intents** — no new
surface, no second code path, nothing the server has to do differently during play. rules-engine surface, no second code path, nothing the server has to do differently during play.
(This paragraph said "state is `fold(events)`" until v0.4.0. It is not: the phase driver mutates state
and then emits a descriptive event, so folding the log rebuilds card plays and switching but not one
train movement or clock tick. The intents are what is canonical.)
Three constraints keep it cheap, and all three are free if honoured from the start: Three constraints keep it cheap, and all three are free if honoured from the start:
@@ -144,7 +154,7 @@ is a lobby setting layered on top of the rules, never part of them.
Worth stating, because small self-hosted scale makes several standard concerns disappear: Worth stating, because small self-hosted scale makes several standard concerns disappear:
- **No horizontal scaling.** A handful of concurrent games fits in one process. Game state lives in - **No horizontal scaling.** A handful of concurrent games fits in one process. Game state lives in
memory; the event log is persisted for durability, not for coordination. memory; the intent history is persisted for durability, not for coordination.
- **No matchmaking service.** Players share a game code (see `lobby-and-sessions.md`). - **No matchmaking service.** Players share a game code (see `lobby-and-sessions.md`).
- **No accounts system, initially.** A display name plus a session token is enough to join and - **No accounts system, initially.** A display name plus a session token is enough to join and
reconnect. Real accounts can be layered on later without touching the game engine. reconnect. Real accounts can be layered on later without touching the game engine.
+14 -5
View File
@@ -11,7 +11,7 @@ that live nowhere else.
| Family | Direction | Authority | | Family | Direction | Authority |
| --- | --- | --- | | --- | --- | --- |
| **Intent** — a proposal, may be rejected | client → server | [`src/engine/intents.ts`](../../src/engine/intents.ts) — `Intent`, 28 variants | | **Intent** — a proposal, may be rejected | client → server | [`src/engine/intents.ts`](../../src/engine/intents.ts) — `Intent`, 28 variants |
| **Event** — a fact, ordered, the game's history | server → clients | [`src/engine/events.ts`](../../src/engine/events.ts) — `GameEvent`, 46 variants | | **Event** — a fact, ordered; narration, not the record (§3) | server → clients | [`src/engine/events.ts`](../../src/engine/events.ts) — `GameEvent`, 46 variants |
| **View** — a redacted projection | server → one client | [`src/sim/view.ts`](../../src/sim/view.ts) — `Frame` and `snapshot(state, …, seat)` | | **View** — a redacted projection | server → one client | [`src/sim/view.ts`](../../src/sim/view.ts) — `Frame` and `snapshot(state, …, seat)` |
| **Rejection** — why an intent was refused | server → one client | `RejectionCode` in `intents.ts` | | **Rejection** — why an intent was refused | server → one client | `RejectionCode` in `intents.ts` |
@@ -79,8 +79,16 @@ which makes them worth logging server-side: a spike means client and server rule
## 3. Events ## 3. Events
Events are the authoritative history: `state = fold(events)`, which buys reconnection, persistence and **Events narrate; they do not reconstruct.** This section claimed `state = fold(events)` until
post-game replay for one design decision. v0.4.0, and it was never true: `applyIntent` goes through `reduce`, but the phase driver mutates state
and *then* emits a descriptive event, so fourteen of the forty-six event types are never reduced — the
clock, and the whole Mainline phase, which is to say every train movement in the game.
**The canonical record is `{ seed, history: Intent[] }`**, replayed by `fromSave`. That is what save,
share, undo, restart recovery and post-game replay all run on, and it is what persistence stores
(`multiplayer.md` §10). Events drive the display and the notifications. Do not build anything on
folding them without first making the phase driver reduce, which is a rewrite of the most rule-dense
code in the project and buys nothing the current plan uses.
**Events must render standalone** — the rule is stated at the top of `events.ts` and it is the one that **Events must render standalone** — the rule is stated at the top of `events.ts` and it is the one that
gets broken by accident. Carry the `from`/`to`, not an id the renderer resolves against live state, gets broken by accident. Carry the `from`/`to`, not an id the renderer resolves against live state,
@@ -92,8 +100,9 @@ Two consequences worth knowing before adding an event:
Fault depends on *where* the wreck happened — Superintendent for a Mainline card, the local player Fault depends on *where* the wreck happened — Superintendent for a Mainline card, the local player
between their Limits (§10) — and getting it wrong misattributes a −5 and, in Competitive, feeds a between their Limits (§10) — and getting it wrong misattributes a −5 and, in Competitive, feeds a
floor that ends the game. floor that ends the game.
- **Events are not what goes over the wire.** The server folds them and pushes the resulting `Frame` - **Events are not what goes over the wire.** The server applies the intent and pushes the resulting
(`multiplayer.md` D2/D3). They are the store and the replay format, not the protocol. `Frame` (`multiplayer.md` D2/D3). Events are narration and cues, not the protocol — and a reconnect
gets a fresh `Frame` rather than the tail it missed.
--- ---
+38 -58
View File
@@ -49,73 +49,53 @@ must do.
## Current status ## Current status
Rules formalized, card faces specified, architecture documented, and the rules engine built and **v0.4.1.** Rules formalized, card faces specified, architecture documented, and the game playable
simulated. Eleven of thirteen gaps are closed; **Gap 12 (balance variance) and Gap 13 (deck scaling) solitaire in a browser. See [`../CHANGELOG.md`](../CHANGELOG.md) for what each version changed and
remain open**, both with data behind them. [`../TODO.md`](../TODO.md) for what is open; this section is the shape of the project, not a
running tally, because a hand-maintained tally is what drifted last time.
Gaps 8–13 were all found by working the design forward — building it or simulating it — rather than **What is built.** The rules engine, the developer bot, the balance harness, the replay viewer and
by reading the PDFs. None arises in tabletop play, where a person simply does the sensible thing. the playable page — components 1–7, 17 and 18 of
[`architecture/components.md`](architecture/components.md). A game can be saved, shared, replayed and
stepped back through. **493 tests.**
**What is not.** The server. Phases 0 and 1 of
[`architecture/multiplayer.md`](architecture/multiplayer.md) landed in v0.4.0 — seat and player are
separate, turn state is per player, and the page talks to a `Session` rather than to the engine, so a
`RemoteSession` drops in without the page changing. Phase 2 onward is **deliberately held** until the
two provisional rules introduced in v0.3.0 have been played at a table: changing a rule after the wire
format is live costs far more than changing it before. Also unbuilt: the 22 opponent-directed cards
and real audio.
**Balance is not where it should be, and no conclusion should be read from the revenue numbers yet.**
The rebalance pass is deliberately deferred until the rules stop moving — card counts, industry counts
and the track mix all need moving together. `TODO.md` carries the standing distortions and the
measurements behind them.
**Three rules are genuinely open**, and they are the reason the README does not claim the rules are
finished: where the Local's coach stands while its engine works (a §A.4 question, not a train-card
question); Poling, the one card in the deck with no defined behaviour; and whether a Heavy Grade's
orientation is rolled or chosen at setup. Thirteen gaps in the prototype rules were found and closed;
these three came after.
**The event log narrates; the intents reconstruct.** Settled in v0.4.0 after the documentation had
claimed `state = fold(events)` for months. It is not true — the phase driver mutates state and then
describes it — so the canonical record is `{ seed, history: Intent[] }` and persistence will be built
on that. [`architecture/protocol.md`](architecture/protocol.md) §3 has the reasoning;
`test/events.test.ts` pins it.
**The economy, in one line:** Local Operations actions are the main currency — one per Stage, twelve **The economy, in one line:** Local Operations actions are the main currency — one per Stage, twelve
per Day — but **inbound work bypasses them**, which is where the game's variance comes from. See per Day — but **inbound work bypasses them**, which is where the game's variance comes from. See
`card-reference.md` §7. `card-reference.md` §7.
**Wanted, not yet designed:** post-game replay — watching a finished game back at speed. The
architecture already produces what it needs (an ordered append-only event log plus a stored seed), so
the note in `architecture/overview.md` exists to keep the capability from being designed out. It is
explicitly not scheduled.
**Provisional numbers** — the victory targets are now confirmed at the mean by simulation. Still
untested by play: Crew Tray count, Laborer counts, and whether the variance in Gap 12 is a flaw.
**Next steps.** The specification is complete enough to build against or to play on paper.
The build path is laid out in [`architecture/components.md`](architecture/components.md) — 20
components, a dependency graph, and a twelve-step order. Its **MVP is solitaire: one Office, a fixed
number of Days, a server talking to a single browser, display functional rather than pretty.** The
milestones worth knowing:
- **Step 4** — a full solitaire game runs to completion headless, proving the rules before a pixel is
drawn.
- **Step 6** — the balance harness validates or retunes the provisional numbers, while changing them
is still free.
- **Step 10** — MVP complete; a human plays end to end.
**Stack: TypeScript**, chosen so the engine runs in both the server and the browser — one **Stack: TypeScript**, chosen so the engine runs in both the server and the browser — one
implementation of the movement rules, and instant affordances without a round-trip. Node 22 runs implementation of the movement rules, and instant affordances without a round-trip. Node 22 runs
TypeScript natively, so there is no build step during development. TypeScript natively, so there is no build step during development, which also means **erasable syntax
only**: no `enum`, no parameter properties, no namespaces.
> **The recovered design files are now transcribed and the nine open questions answered** — see Running alongside, and independent of all of it: **print-and-play components.** `card-reference.md`
> [`rules/implications.md`](rules/implications.md) §1b for exactly what is implemented. The deck is specifies every card face, so layout and art are the only remaining work before a table playtest —
> 115 cards (93 in solitaire), Mainline cards have terrain and crossing times, and the balance which answers the one question simulation cannot, whether it is fun.
> numbers below predate all of it.
**Steps 1–6 are done** — the rules engine (components 1–7), heuristic bots (17) and the balance
harness (18), with 134 tests passing.
**The step 4 milestone is met: a full solitaire game runs to completion, headless.** Games are
reproducible from a seed, terminate from every seed tried, conserve all 62 rolling stock pieces, and
develop properly.
**End-of-game statistics** (`src/sim/stats.ts`) report revenue by source, traffic, development,
action mix and strategy buckets — plus an **anomaly detector** that treats "this event never fired"
as a finding. It caught two bugs on its first run.
**The step 6 milestone is met, with a caveat.** The harness found **five engine bugs that no test had
caught**, and once they were fixed the numbers came good: **~35% win rate and 5.0 Revenue/player/Day,
matching the Gap 10e prediction of 5-6.**
The caveat is large: those numbers were themselves measured against a **scoring bug**, since fixed.
Honest figures are ~0.9 Revenue/Day and a **0% win rate** — the targets are currently unreachable.
The bot is still the prime suspect (it uses ~10 Laborer actions per game out of ~900 available), so
this is **Gap 12** and remains open. One genuine signal did emerge: **mixed freight/passenger
strategies outscore pure ones**, with pure freight much the worst.
**Gap 11 is decided** — track orientation is chosen on placement, settled by measurement.
**Gap 13** answers the deck-size question: do not double it; scale only the buildable cards with
player count.
Next: step 7 begins the server (components 8, 9, 20).
Run the harness with `node src/sim/harness.ts [games] [length]`. Run the harness with `node src/sim/harness.ts [games] [length]`.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "station-master", "name": "station-master",
"version": "0.4.0", "version": "0.4.1",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Station Master — a railroad operations game", "description": "Station Master — a railroad operations game",
+3 -3
View File
@@ -39,7 +39,7 @@ import type { GameEvent } from './events.ts';
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, PlayerIndex, RollingStock, SeatIndex, TrayId } from './state.ts';
import { coordKey, freshTurns, playerAtSeat, subdivisions, totalRevenue, turnOf } from './state.ts'; import { coordKey, freshTurns, playerAtSeat, playerLeftOf, subdivisions, totalRevenue, turnOf } from './state.ts';
export type AdvanceResult = { export type AdvanceResult = {
events: GameEvent[]; events: GameEvent[];
@@ -147,7 +147,7 @@ function playerPhase(
} }
function actorAt(s: GameState, offset: number): PlayerIndex { function actorAt(s: GameState, offset: number): PlayerIndex {
return (s.clock.superintendent + offset) % s.players.length; return playerLeftOf(s, s.clock.superintendent, offset);
} }
function nextPhase(p: GameState['clock']['phase']): GameState['clock']['phase'] { function nextPhase(p: GameState['clock']['phase']): GameState['clock']['phase'] {
@@ -972,7 +972,7 @@ function retireTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent
function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult { function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
// §5 — the Fedora passes every three Stages: shift changes at Stages 3, 6, 9 and 12. // §5 — the Fedora passes every three Stages: shift changes at Stages 3, 6, 9 and 12.
if (s.clock.stage % STAGES_PER_SHIFT === 0) { if (s.clock.stage % STAGES_PER_SHIFT === 0) {
s.clock.superintendent = (s.clock.superintendent + 1) % s.players.length; s.clock.superintendent = playerLeftOf(s, s.clock.superintendent);
events.push({ type: 'actorChanged', player: s.clock.superintendent }); events.push({ type: 'actorChanged', player: s.clock.superintendent });
} }
+7 -3
View File
@@ -9,8 +9,8 @@
* - `execute` reads state and emits events; it never mutates either. * - `execute` reads state and emits events; it never mutates either.
* - `reduce` is the only thing that mutates, folding events into state. * - `reduce` is the only thing that mutates, folding events into state.
* *
* That keeps `state = fold(events)` true by construction, which is what makes replay and restart * That keeps every INTENT-driven change reducible by construction. Replay and restart recovery run
* recovery work. It also lets component 6 (legalActions) call these very same `check` functions, * on the intents themselves (`protocol.md` §3), not on folding the log. It also lets component 6 (legalActions) call these very same `check` functions,
* so the two can never drift apart — see legal.ts. * so the two can never drift apart — see legal.ts.
*/ */
@@ -1380,7 +1380,11 @@ function findTimetableSlot(s: GameState, from: number): number | null {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// reduce — the ONLY mutator. state = fold(events). // reduce — the only mutator on the INTENT path.
//
// `applyIntent` never touches state except through here, so everything a player does is reducible.
// The phase driver (`advance.ts`) does not: it mutates and then describes, so folding the whole log
// does NOT reconstruct a game. `protocol.md` §3 has the consequence — the intents are canonical.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function reduce(s: GameState, e: GameEvent): void { export function reduce(s: GameState, e: GameEvent): void {
+12 -3
View File
@@ -1,9 +1,18 @@
/** /**
* Events — protocol.md §3. * Events — protocol.md §3.
* *
* An event is a FACT. Events are ordered, append-only, and fully determine state: * An event is a FACT: ordered, append-only, and standalone. Events NARRATE the game — they drive the
* `state = fold(events)`. That one property gives reconnection, restart recovery and post-game * log, the sounds and the replay's captions.
* replay together. *
* THEY DO NOT RECONSTRUCT IT. This header claimed `state = fold(events)` until v0.4.0 and it was
* never true. `applyIntent` does go through `reduce`, but the phase driver in `advance.ts` mutates
* state and THEN emits a descriptive event, so fourteen of the forty-six types below are never
* reduced — the clock, and the whole Mainline phase, which is every train movement in the game.
*
* The canonical record is `{ seed, history: Intent[] }`, replayed by `fromSave`. That is what save,
* restore, undo, restart recovery and post-game replay all run on. See
* `docs/architecture/protocol.md` §3, and `test/events.test.ts`, which pins the unreduced set so
* that closing the gap is a deliberate act rather than a surprise.
* *
* DESIGN RULE (overview.md, post-game replay): events must render STANDALONE. Carry the from/to, * DESIGN RULE (overview.md, post-game replay): events must render STANDALONE. Carry the from/to,
* not just an id the renderer has to resolve against live state — otherwise a replay viewer has to * not just an id the renderer has to resolve against live state — otherwise a replay viewer has to
+29 -4
View File
@@ -256,14 +256,35 @@ export function createGame(opts: SetupOptions): GameState {
// seat/player split behaviour-neutral. Employee Rotation would rotate this array and nothing else. // seat/player split behaviour-neutral. Employee Rotation would rotate this array and nothing else.
const officeAreas = new Map<SeatIndex, OfficeArea>(); const officeAreas = new Map<SeatIndex, OfficeArea>();
for (let seat = 0; seat < playerCount; seat++) officeAreas.set(seat, buildOfficeArea(seat)); for (let seat = 0; seat < playerCount; seat++) officeAreas.set(seat, buildOfficeArea(seat));
const seating: PlayerIndex[] = Array.from({ length: playerCount }, (_, seat) => seat);
// §4.4 - highest D12 takes the Eastern Division Point; §4.5 - highest begins as Superintendent. // §4.4 - highest D12 takes the Eastern Division Point; §4.5 - highest begins as Superintendent.
// Both rolls are drawn even in solitaire so the RNG stream stays identical across player counts. // Both rolls are drawn even in solitaire so the RNG stream stays identical across player counts.
const divisionRolls = players.map(() => rng.d12()); const divisionRolls = players.map(() => rng.d12());
const superRolls = players.map(() => rng.d12()); const superRolls = players.map(() => rng.d12());
const superintendent = argmax(superRolls); const superintendent = argmax(superRolls);
void divisionRolls; // seating is fixed by array order; the roll is recorded for the event log
/**
* §4.4 — THE OPENING D12 DECIDES WHO SITS WHERE.
*
* `seating[seat] = player`, and seat 0 is the WESTERN end of the chain (`buildDivision` lays the
* Western Division Point, then office 0, and finishes at the Eastern one). So the highest roll
* takes the last seat — "highest is the Eastern Division Point" — and the lowest ends up beside
* the Western Division Point, which is the rule's other named position.
*
* The rule names only those two ends, because at a table the players are already sitting in a
* chain and the roll only says which way round it is. There is no physical table here, so the
* roll orders everybody: ascending by roll, west to east. It uses a number every player is
* already told to roll, and it makes the roll matter to more than the winner.
*
* Ties break toward the LOWER player index sitting further east, which is the same convention
* `argmax` uses for the Superintendent roll on the line above — first max wins.
*
* This was `void divisionRolls` until v0.4.1: the roll was drawn and discarded, and seating was
* the identity mapping. Turning it on is what makes seat and player genuinely different at
* runtime rather than only in the type names.
*/
const seating: PlayerIndex[] = players
.map((_, p) => p)
.sort((a, b) => divisionRolls[a]! - divisionRolls[b]! || b - a);
/** /**
* §4.6-4.7 — THE OPENING DEAL, dealt from two piles rather than one. * §4.6-4.7 — THE OPENING DEAL, dealt from two piles rather than one.
@@ -297,8 +318,11 @@ export function createGame(opts: SetupOptions): GameState {
const hands = new Map<PlayerIndex, CardId[]>(); const hands = new Map<PlayerIndex, CardId[]>();
let trackCursor = 0; let trackCursor = 0;
let otherCursor = 0; let otherCursor = 0;
// §4.7 — "starting from the Superintendent, deal each player…", which proceeds round the table
// and is therefore seat order, not player order.
const superSeat = seating.indexOf(superintendent);
for (let i = 0; i < playerCount; i++) { for (let i = 0; i < playerCount; i++) {
const p = (superintendent + i) % playerCount; const p = seating[(superSeat + i) % playerCount]!;
hands.set(p, [ hands.set(p, [
...trackPile.slice(trackCursor, trackCursor + OPENING_TRACK), ...trackPile.slice(trackCursor, trackCursor + OPENING_TRACK),
...otherPile.slice(otherCursor, otherCursor + OPENING_OTHER), ...otherPile.slice(otherCursor, otherCursor + OPENING_OTHER),
@@ -340,6 +364,7 @@ export function createGame(opts: SetupOptions): GameState {
rngState: rng.getState(), rngState: rng.getState(),
players, players,
seating, seating,
openingRolls: { division: divisionRolls, superintendent: superRolls },
division: { nodes: buildDivision(playerCount, rng) }, division: { nodes: buildDivision(playerCount, rng) },
officeAreas, officeAreas,
trays: new Map(), trays: new Map(),
+26 -3
View File
@@ -543,12 +543,19 @@ export type GameState = {
rngState: number; rngState: number;
players: Player[]; players: Player[];
/** /**
* Who is sitting where: `seating[seat] = player`. * Who is sitting where: `seating[seat] = player`, seat 0 at the WESTERN end of the chain.
* *
* The identity mapping in every game today, which is what makes the seat/player split * Decided at setup by §4.4's D12 (`openingRolls.division`) — a real permutation, not the identity,
* behaviour-neutral. Employee Rotation would rotate this array and nothing else. * except in solitaire where one player means one seat. Employee Rotation would rotate this array
* and nothing else.
*/ */
seating: PlayerIndex[]; seating: PlayerIndex[];
/**
* The two opening D12s, per player, kept so a client can show the rolls rather than only their
* outcome — it is the game's first moment of drama (`lobby-and-sessions.md` §4). Indexed by
* PLAYER, since that is who rolls.
*/
openingRolls: { division: number[]; superintendent: number[] };
division: Division; division: Division;
officeAreas: Map<SeatIndex, OfficeArea>; officeAreas: Map<SeatIndex, OfficeArea>;
trays: Map<TrayId, CrewTray>; trays: Map<TrayId, CrewTray>;
@@ -623,6 +630,22 @@ export function playerAtSeat(state: GameState, seat: SeatIndex): PlayerIndex {
} }
/** Where this player is sitting, and therefore which Office Area is theirs. */ /** Where this player is sitting, and therefore which Office Area is theirs. */
/**
* The player `n` seats to the LEFT of this one, wrapping round the table.
*
* Acting order, the deal and the Fedora are all "starting here and proceeding left" (Gap 1, §4.7,
* §5), which is a statement about the physical chain of Offices — so it is seat arithmetic, not
* player arithmetic. All three used to do `(player + n) % players`, which was the same thing only
* while seating was the identity mapping. It stopped being that when §4.4's D12 started deciding
* who sits where.
*
* "Left" is increasing seat index, i.e. eastward along the chain, matching what the shift-change
* tests have always asserted.
*/
export function playerLeftOf(state: GameState, player: PlayerIndex, n = 1): PlayerIndex {
return playerAtSeat(state, (seatOf(state, player) + n) % state.seating.length);
}
export function seatOf(state: GameState, player: PlayerIndex): SeatIndex { export function seatOf(state: GameState, player: PlayerIndex): SeatIndex {
const seat = state.seating.indexOf(player); const seat = state.seating.indexOf(player);
if (seat < 0) throw new Error(`player ${player} is not seated`); if (seat < 0) throw new Error(`player ${player} is not seated`);
+1 -1
View File
@@ -1691,7 +1691,7 @@ export type PlayOutcome = {
trainsScheduled: number; trainsScheduled: number;
cardsPlayed: number; cardsPlayed: number;
outcome: GameState['outcome']; outcome: GameState['outcome'];
/** The full ordered log. `state = fold(events)`, so this is the complete record of the game. */ /** The full ordered log — everything the game emitted, in order. Narration, not a reducible record. */
events: GameEvent[]; events: GameEvent[];
/** Every intent the bot actually submitted, for action-mix analysis. */ /** Every intent the bot actually submitted, for action-mix analysis. */
intents: Intent['type'][]; intents: Intent['type'][];
+4 -3
View File
@@ -1,9 +1,10 @@
/** /**
* End-of-game statistics. * End-of-game statistics.
* *
* Dev-side. Compiles a readable account of how a game actually went, from the event log. Because * Dev-side. Compiles a readable account of how a game actually went, from the event log. Every event
* `state = fold(events)`, the log is the complete record — nothing needs instrumenting in the * the game emits is in there, so nothing needs instrumenting in the engine to produce any of this —
* engine to produce any of this. * which is a claim about COVERAGE, not about reducibility: the log narrates the game completely and
* reconstructs it not at all (`protocol.md` §3).
* *
* Three purposes, in ascending order of usefulness: * Three purposes, in ascending order of usefulness:
* *
+9 -2
View File
@@ -289,8 +289,14 @@ export type Frame = {
option: 'switch' | 'draw' | 'freightAgent' | null; option: 'switch' | 'draw' | 'freightAgent' | null;
status: GameState['status']; status: GameState['status'];
outcome: GameState['outcome']; outcome: GameState['outcome'];
/** Every seat's public standing — names and Revenue. "The race is the game" (protocol.md §4). */ /**
players: { index: number; name: string; revenue: number; hand: number }[]; * Every PLAYER's public standing — names and Revenue. "The race is the game" (protocol.md §4).
*
* In player order, not seat order, because the list is about people. `seat` is carried so a client
* that wants to draw the table west-to-east can sort by it — which stopped being the same thing as
* player order once §4.4's D12 decided who sits where.
*/
players: { index: number; seat: number; name: string; revenue: number; hand: number }[];
/** How many cards the VIEWER holds. Other players' counts are in `players`. */ /** How many cards the VIEWER holds. Other players' counts are in `players`. */
handCount: number; handCount: number;
lines: { text: string; tone: string }[]; lines: { text: string; tone: string }[];
@@ -1042,6 +1048,7 @@ export function snapshot(
outcome: s.outcome, outcome: s.outcome,
players: s.players.map((p) => ({ players: s.players.map((p) => ({
index: p.index, index: p.index,
seat: seatOf(s, p.index),
name: p.name, name: p.name,
revenue: p.revenue, revenue: p.revenue,
hand: (s.decks.hands.get(p.index) ?? []).length, hand: (s.decks.hands.get(p.index) ?? []).length,
+4 -3
View File
@@ -17,9 +17,10 @@
* legal would eventually disagree with `check`, and the failure mode is a UI that offers an illegal * legal would eventually disagree with `check`, and the failure mode is a UI that offers an illegal
* move or refuses a legal one. * move or refuses a legal one.
* *
* SAVING. The event log is the game (`state = fold(events)`), and the RNG is seeded, so a save is * SAVING. The intents ARE the game — the RNG is seeded and `applyIntent` is deterministic — so a save
* the seed plus the list of intents submitted. Replaying them reconstructs the position exactly, * is the seed plus the list of intents submitted. Replaying them reconstructs the position exactly,
* which is far smaller and far more robust than serialising the state graph. * which is far smaller and far more robust than serialising the state graph. (Not the event log:
* folding events does not rebuild a game — `protocol.md` §3.)
*/ */
import { pump } from '../engine/advance.ts'; import { pump } from '../engine/advance.ts';
+128
View File
@@ -0,0 +1,128 @@
/**
* What the event log is, and what it is not.
*
* The README, four architecture documents and six source comments all claimed `state = fold(events)`
* until v0.4.0. It was never true, and the claim was load-bearing — it was the stated justification
* for reconnection, restart recovery and persistence, none of which were built yet. This file makes
* the real shape checkable so the claim cannot quietly come back.
*
* The decision (`docs/architecture/protocol.md` §3): **the intents are canonical.** A game is
* `{ seed, history: Intent[] }`, `fromSave` replays it exactly, and events narrate.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fromSave, newGame, submit, toSave, view } from '../src/web/game.ts';
import { actionGroups, currentActor } from '../src/web/game.ts';
const src = (rel: string): string => readFileSync(join(import.meta.dirname, '..', 'src', rel), 'utf8');
/** Every `type: 'x'` in the `GameEvent` union. */
function eventTypes(): Set<string> {
const text = src('engine/events.ts');
const union = text.slice(text.indexOf('export type GameEvent ='));
return new Set([...union.matchAll(/type: '([a-zA-Z]+)'/g)].map((m) => m[1]!));
}
/** Every `case 'x':` inside `reduce`. */
function reducedTypes(): Set<string> {
const text = src('engine/apply.ts');
const body = text.slice(text.indexOf('export function reduce'));
return new Set([...body.matchAll(/case '([a-zA-Z]+)':/g)].map((m) => m[1]!));
}
/**
* The event types the reducer does not handle, as of v0.4.0.
*
* Every one is emitted by the phase driver in `advance.ts`, which mutates state and then describes
* what it did. Read the list: it is the clock, plus the entire Mainline phase — which is to say every
* train movement in the game. That is why folding the log rebuilds a district and not a railroad.
*/
const KNOWN_UNREDUCED = [
'actorChanged',
'carPassed',
'clearanceRequested',
'dispatchBonusUsed',
'phaseBegan',
'stageBegan',
'trainArrived',
'trainCompleted',
'trainDiverted',
'trainHeld',
'trainHighballed',
'trainMadeUp',
'trainStoodStill',
'trainsDestroyed',
];
describe('the event log narrates but does not reconstruct', () => {
it('has exactly the unreduced event types it is documented to have', () => {
/**
* A CHANGE-DETECTOR ON PURPOSE. If this fails because the list shrank, someone has made the
* phase driver reduce — good, and the docs in `protocol.md` §3, `events.ts` and the README now
* understate the engine and should be corrected in the same change. If it fails because the list
* GREW, a new event was added on the mutate-then-describe path, which is worth knowing before it
* becomes another thing the log cannot rebuild.
*/
const all = eventTypes();
const reduced = reducedTypes();
const unreduced = [...all].filter((t) => !reduced.has(t)).sort();
assert.deepEqual(
unreduced,
KNOWN_UNREDUCED,
'the set of events the reducer ignores has changed — see the comment above KNOWN_UNREDUCED',
);
});
it('never folds events in the phase driver, which is what makes the above true', () => {
// `advance.ts` mutating directly is the whole mechanism. If it ever starts calling `reduce`,
// the claim becomes recoverable and this file should be rewritten rather than relaxed.
assert.doesNotMatch(
src('engine/advance.ts'),
/\breduce\(s[,)]/,
'the phase driver now folds events — reconsider the canonical-record decision',
);
});
});
describe('the intents are what reconstructs a game', () => {
it('replays a partly-played game to exactly the same position', () => {
// The property that actually holds, stated as a test rather than as a comment. This is what
// save, share, undo, restart recovery and post-game replay all rest on.
const game = newGame(31337);
for (let i = 0; i < 120; i++) {
if (currentActor(game) === null) break;
const { options } = actionGroups(game);
if (options.length === 0) break;
if (!submit(game, options[0]!)) break;
}
assert.ok(game.history.length > 20, 'the driver did not play far enough to be a real test');
const back = fromSave(toSave(game));
assert.deepEqual(view(back).cells, view(game).cells, 'the board differs after replay');
assert.deepEqual(
back.state.players.map((p) => p.revenue),
game.state.players.map((p) => p.revenue),
'the score differs after replay',
);
assert.equal(back.state.clock.day, game.state.clock.day);
assert.equal(back.state.clock.stage, game.state.clock.stage);
assert.equal(back.state.clock.phase, game.state.clock.phase);
// The trains are the part folding the log would have lost, so check them specifically.
assert.deepEqual(
[...back.state.trays.keys()].sort(),
[...game.state.trays.keys()].sort(),
'the crews differ after replay',
);
});
it('carries no state in the save beyond the seed and the intents', () => {
// If anything else ever creeps into `Save`, the claim above weakens: the game would no longer be
// reconstructible from decisions alone, and persistence would have a schema to migrate.
const game = newGame(7);
assert.deepEqual(Object.keys(toSave(game)).sort(), ['history', 'seed']);
});
});
+86 -41
View File
@@ -11,7 +11,7 @@ import { describe, it } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { advance, pump } from '../src/engine/advance.ts'; import { advance, pump } from '../src/engine/advance.ts';
import { areaOf } from '../src/engine/apply.ts'; import { areaAtSeat, areaOf } from '../src/engine/apply.ts';
import { STAGES_PER_SHIFT, crewTrayCount } from '../src/engine/content.ts'; import { STAGES_PER_SHIFT, crewTrayCount } from '../src/engine/content.ts';
import { createGame } from '../src/engine/setup.ts'; import { createGame } from '../src/engine/setup.ts';
import type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.ts'; import type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.ts';
@@ -42,7 +42,7 @@ function readyToLeave(s: GameState, owner: PlayerIndex, id: string, direction: '
const area = areaOf(s, owner); const area = areaOf(s, owner);
s.trays.set(id, { s.trays.set(id, {
id, trainNumber: 9, trainIsExtra: false, engineAt: 0, consist: [], id, trainNumber: 9, trainIsExtra: false, engineAt: 0, consist: [],
direction, position: { at: 'grid', seat: owner, coord: area.officeCoord }, movesUsed: 0, direction, position: { at: 'grid', seat: seatOf(s, owner), coord: area.officeCoord }, movesUsed: 0,
}); });
area.adOccupancy.push(id); area.adOccupancy.push(id);
} }
@@ -93,8 +93,11 @@ describe('multi-player games run at all', () => {
for (const players of [2, 3, 4]) { for (const players of [2, 3, 4]) {
const s = game(players); const s = game(players);
assert.equal(s.officeAreas.size, players, `${players}p office areas`); assert.equal(s.officeAreas.size, players, `${players}p office areas`);
for (let p = 0; p < players; p++) { // Every seat is occupied by exactly one player, and that player's district is that seat's.
assert.equal(areaOf(s, p).seat, p, 'an Office Area is owned by the wrong seat'); // Which player sits where is decided by §4.4's D12, so it is a permutation, not the identity.
assert.deepEqual([...s.seating].sort((a, b) => a - b), [...Array(players).keys()]);
for (let seat = 0; seat < players; seat++) {
assert.equal(areaOf(s, playerAtSeat(s, seat)).seat, seat, 'a seat holds the wrong district');
} }
// §7 — trays are scarce on purpose, and the count is per player count. // §7 — trays are scarce on purpose, and the count is per player count.
assert.equal(s.freeTrays.length, crewTrayCount(players), `${players}p crew trays`); assert.equal(s.freeTrays.length, crewTrayCount(players), `${players}p crew trays`);
@@ -107,8 +110,10 @@ describe('the Fedora goes round the table', () => {
* Watches the Superintendent while the BOT plays the game. * Watches the Superintendent while the BOT plays the game.
* *
* `advance` stops and asks for input rather than driving itself, so calling it in a loop never * `advance` stops and asks for input rather than driving itself, so calling it in a loop never
* moves the clock — the game has to actually be played. A spy policy records the seat holding the * moves the clock — the game has to actually be played. A spy policy records the SEAT holding the
* Fedora each time a decision is asked for. * Fedora each time a decision is asked for: §5 passes it round the table, and since §4.4's D12
* decides who sits where, the sequence of player indices is a permutation while the sequence of
* seats is the plain 0, 1, 2, … that the rule describes.
*/ */
const superintendentsSeen = (players: number): number[] => { const superintendentsSeen = (players: number): number[] => {
const s = game(players); const s = game(players);
@@ -116,7 +121,7 @@ describe('the Fedora goes round the table', () => {
const spy = { const spy = {
name: 'spy', name: 'spy',
choose(st: GameState, p: PlayerIndex, opts: Parameters<typeof developerBot.choose>[2]) { choose(st: GameState, p: PlayerIndex, opts: Parameters<typeof developerBot.choose>[2]) {
const who = st.clock.superintendent; const who = seatOf(st, st.clock.superintendent);
if (seen[seen.length - 1] !== who) seen.push(who); if (seen[seen.length - 1] !== who) seen.push(who);
return developerBot.choose(st, p, opts); return developerBot.choose(st, p, opts);
}, },
@@ -183,13 +188,16 @@ describe('Subdivisions are split by whoever is a Control Point', () => {
const s = game(4); const s = game(4);
assert.equal(subdivisions(s).length, 1, 'four Whistle Posts should leave one Subdivision'); assert.equal(subdivisions(s).length, 1, 'four Whistle Posts should leave one Subdivision');
areaOf(s, 1).tier = 'depot'; // BY SEAT: a Subdivision is a stretch of the physical chain, and §4.4's D12 decides which
// player is sitting in which stretch. Upgrading "player 1's" Office would upgrade whichever
// seat they happen to hold, which is not what this test is about.
areaAtSeat(s, 1).tier = 'depot';
const split = subdivisions(s); const split = subdivisions(s);
assert.equal(split.length, 2, 'a Control Point should cut the Division in two'); assert.equal(split.length, 2, 'a Control Point should cut the Division in two');
// The upgraded Office is a BOUNDARY, so it appears in neither group; the others still sit inside. // The upgraded Office is a BOUNDARY, so it appears in neither group; the others still sit inside.
const officeIndex = (owner: number): number => const officeIndex = (seat: number): number =>
s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === owner); s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === seat);
const all = split.flat(); const all = split.flat();
assert.ok(!all.includes(officeIndex(1)), 'the Control Point is still inside a Subdivision'); assert.ok(!all.includes(officeIndex(1)), 'the Control Point is still inside a Subdivision');
for (const other of [0, 2, 3]) { for (const other of [0, 2, 3]) {
@@ -199,7 +207,7 @@ describe('Subdivisions are split by whoever is a Control Point', () => {
it('gives every Office its own Subdivision once they are all Control Points', () => { it('gives every Office its own Subdivision once they are all Control Points', () => {
const s = game(4); const s = game(4);
for (let p = 0; p < 4; p++) areaOf(s, p).tier = 'terminal'; for (let seat = 0; seat < 4; seat++) areaAtSeat(s, seat).tier = 'terminal';
// Five Mainline cards, each now bounded by a Control Point or a Division Point. // Five Mainline cards, each now bounded by a Control Point or a Division Point.
assert.equal(subdivisions(s).length, 5, 'each Mainline card should be its own Subdivision'); assert.equal(subdivisions(s).length, 5, 'each Mainline card should be its own Subdivision');
}); });
@@ -268,18 +276,41 @@ describe("one player's train blocks another's", () => {
}); });
describe('a seat is a place, a player is a person', () => { describe('a seat is a place, a player is a person', () => {
it('starts with the identity mapping, so the split changes nothing today', () => { it('seats everyone exactly once, and seats them by the §4.4 roll', () => {
// The whole seat/player split is behaviour-neutral until something rotates `seating`. This is /**
// what makes that claim checkable rather than asserted. * `seating` was the identity mapping until §4.4's D12 was wired up, which meant the whole
for (const players of [1, 2, 3, 4]) { * seat/player distinction was untested at runtime — every mix-up of the two was silently
const s = players === 1 * correct. It is a real permutation now, and this is what says so.
? createGame({ id: 's', seed: 4242, config: { ...competitive, mode: 'solitaire' }, playerNames: ['a'] }) *
: game(players); * Solitaire is the exception and must stay one: with a single player there is one seat, the
assert.deepEqual(s.seating, [...Array(players).keys()], `${players}p seating is not the identity`); * permutation is trivially the identity, and every replay depends on that.
*/
for (const players of [2, 3, 4]) {
const s = game(players);
assert.deepEqual([...s.seating].sort((a, b) => a - b), [...Array(players).keys()], 'not a permutation');
for (let p = 0; p < players; p++) { for (let p = 0; p < players; p++) {
assert.equal(seatOf(s, p), p); assert.equal(playerAtSeat(s, seatOf(s, p)), p, 'seatOf and playerAtSeat disagree');
assert.equal(playerAtSeat(s, p), p); assert.equal(areaOf(s, p).seat, seatOf(s, p), 'a player is looking at the wrong district');
assert.equal(areaOf(s, p).seat, p); }
}
const solo = createGame({ id: 's', seed: 4242, config: { ...competitive, mode: 'solitaire' }, playerNames: ['a'] });
assert.deepEqual(solo.seating, [0], 'solitaire seating must stay the identity — replays depend on it');
});
it('seats the highest roller at the eastern end of the chain', () => {
// §4.4 — "highest is the Eastern Division Point". `buildDivision` lays west-to-east, so the
// eastern end is the LAST seat. Checked over many seeds rather than one, because a single deal
// could satisfy this by luck.
for (let seed = 1; seed <= 40; seed++) {
const s = game(3, seed);
const rolls = s.openingRolls.division;
const east = s.seating[s.seating.length - 1]!;
const best = Math.max(...rolls);
assert.equal(rolls[east], best, `seed ${seed}: the easternmost seat is not the highest roll`);
// And the chain runs low-to-high west to east, so nobody east of you rolled lower.
for (let i = 1; i < s.seating.length; i++) {
assert.ok(rolls[s.seating[i - 1]!]! <= rolls[s.seating[i]!]!, `seed ${seed}: the chain is not ordered`);
} }
} }
}); });
@@ -293,19 +324,26 @@ describe('a seat is a place, a player is a person', () => {
*/ */
const s = game(3); const s = game(3);
const officeOf = (p: PlayerIndex): number => areaOf(s, p).seat; const officeOf = (p: PlayerIndex): number => areaOf(s, p).seat;
assert.deepEqual([0, 1, 2].map(officeOf), [0, 1, 2]); const before = [0, 1, 2].map(officeOf);
// Mark each Office so we can see which one a player is looking at. // Mark each Office so we can see which one a player is looking at. BY SEAT — the Offices are
for (let seat = 0; seat < 3; seat++) areaOf(s, seat).tier = (['depot', 'station', 'terminal'] as const)[seat]!; // the furniture, and the point of the test is that the furniture stays put.
const tiers = ['depot', 'station', 'terminal'] as const;
for (let seat = 0; seat < 3; seat++) areaAtSeat(s, seat).tier = tiers[seat]!;
const tierOf = (p: PlayerIndex): string => areaOf(s, p).tier; const tierOf = (p: PlayerIndex): string => areaOf(s, p).tier;
assert.deepEqual([0, 1, 2].map(tierOf), ['depot', 'station', 'terminal']); assert.deepEqual([0, 1, 2].map(tierOf), before.map((seat) => tiers[seat]!));
// Everyone shuffles one chair along. The Offices do not move; the people do. // Everyone shuffles one chair along: whoever was at seat n is now at seat n+1.
s.seating = [2, 0, 1]; const rotated = [...s.seating];
rotated.unshift(rotated.pop()!);
s.seating = rotated;
assert.deepEqual([0, 1, 2].map(officeOf), [1, 2, 0], 'players did not move seats'); assert.deepEqual([0, 1, 2].map(officeOf), before.map((seat) => (seat + 1) % 3), 'players did not move seats');
assert.deepEqual([0, 1, 2].map(tierOf), ['station', 'terminal', 'depot'], 'the Offices moved with them'); assert.deepEqual(
assert.equal(playerAtSeat(s, 0), 2, 'seat 0 should now be occupied by player 2'); [0, 1, 2].map(tierOf),
before.map((seat) => tiers[(seat + 1) % 3]!),
'the Offices moved with them instead of staying put',
);
// Revenue belongs to the person and must NOT have moved with the chair. // Revenue belongs to the person and must NOT have moved with the chair.
assert.equal(s.players[0]!.index, 0, 'a player index changed when the seating rotated'); assert.equal(s.players[0]!.index, 0, 'a player index changed when the seating rotated');
}); });
@@ -335,19 +373,26 @@ describe('a seat is a place, a player is a person', () => {
* their old chair, naming squares that are not on the board in front of them. * their old chair, naming squares that are not on the board in front of them.
*/ */
const s = game(3); const s = game(3);
const area = areaOf(s, 1); // Put it in a SEAT, and work out who is sitting there.
const area = areaAtSeat(s, 1);
area.grid.set(coordKey({ row: area.runningRow - 1, col: 0 }), idleIndustry()); area.grid.set(coordKey({ row: area.runningRow - 1, col: 0 }), idleIndustry());
const occupant = playerAtSeat(s, 1);
const withImpediments = (): PlayerIndex[] => const withImpediments = (): PlayerIndex[] =>
[0, 1, 2].filter((p) => impediments(s, p).length > 0); [0, 1, 2].filter((p) => impediments(s, p).length > 0);
assert.deepEqual(withImpediments(), [1], 'the industry is not reported to its own player'); assert.deepEqual(withImpediments(), [occupant], 'the industry is not reported to its own player');
// Everyone shuffles one chair along: player 0 now sits at seat 1, so the industry is theirs. // Everyone shuffles one chair along, so seat 1 changes hands.
s.seating = [2, 0, 1]; const rotated = [...s.seating];
assert.deepEqual(withImpediments(), [0], 'the impediment did not follow the chair'); rotated.unshift(rotated.pop()!);
s.seating = rotated;
const newOccupant = playerAtSeat(s, 1);
assert.notEqual(newOccupant, occupant, 'the rotation did not move anybody into seat 1');
assert.deepEqual(withImpediments(), [newOccupant], 'the impediment did not follow the chair');
assert.deepEqual( assert.deepEqual(
[0, 1, 2].map((p) => snapshot(s, [], null, null, null, false, p).blocked.length > 0), [0, 1, 2].map((p) => snapshot(s, [], null, null, null, false, p).blocked.length > 0),
[true, false, false], [0, 1, 2].map((p) => p === newOccupant),
'the Frame disagrees with impediments after a rotation', 'the Frame disagrees with impediments after a rotation',
); );
}); });
@@ -523,13 +568,13 @@ describe('scoring lands on the right seat', () => {
// A card laid in one player's district must not appear in another's — the areas are separate maps // A card laid in one player's district must not appear in another's — the areas are separate maps
// and it would be easy for a shared reference to make every district the same district. // and it would be easy for a shared reference to make every district the same district.
const s = game(3); const s = game(3);
const mine = areaOf(s, 1); const mine = areaAtSeat(s, 1);
const sizeBefore = s.officeAreas.get(2)!.grid.size; const sizeBefore = areaAtSeat(s, 2).grid.size;
mine.grid.set(coordKey({ row: mine.runningRow - 1, col: 0 }), { mine.grid.set(coordKey({ row: mine.runningRow - 1, col: 0 }), {
geometry: { kind: 'track', geometry: 'straight' }, geometry: { kind: 'track', geometry: 'straight' },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [], baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
}); });
assert.equal(s.officeAreas.get(2)!.grid.size, sizeBefore, "one player's track appeared in another's district"); assert.equal(areaAtSeat(s, 2).grid.size, sizeBefore, "one seat's track appeared in another's district");
assert.notEqual(areaOf(s, 1), areaOf(s, 2), 'two seats share one Office Area object'); assert.notEqual(areaAtSeat(s, 1), areaAtSeat(s, 2), 'two seats share one Office Area object');
}); });
}); });