From a7221dcf20535a512dcc255a9ccffb658e7a0bd4 Mon Sep 17 00:00:00 2001 From: Jesse Date: Thu, 13 Aug 2026 15:28:53 -0400 Subject: [PATCH] v0.4.1 - bug fixes. initial d12 rolls determine what player in which seat. --- CHANGELOG.md | 118 +++++++++++++ README.md | 17 +- TODO.md | 30 +++- docs/architecture/components.md | 21 +-- docs/architecture/deployment.md | 2 +- docs/architecture/lobby-and-sessions.md | 213 +++++++++++++++++------- docs/architecture/multiplayer.md | 54 ++++-- docs/architecture/overview.md | 26 ++- docs/architecture/protocol.md | 19 ++- docs/design.md | 96 +++++------ package.json | 2 +- src/engine/advance.ts | 6 +- src/engine/apply.ts | 10 +- src/engine/events.ts | 15 +- src/engine/setup.ts | 33 +++- src/engine/state.ts | 29 +++- src/sim/bot.ts | 2 +- src/sim/stats.ts | 7 +- src/sim/view.ts | 11 +- src/web/game.ts | 7 +- test/events.test.ts | 128 ++++++++++++++ test/multiplayer.test.ts | 127 +++++++++----- 22 files changed, 736 insertions(+), 237 deletions(-) create mode 100644 test/events.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 629dd7a..c25a3c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,124 @@ page as `v0.1.0 · · `, so what is deployed can always be identifie ## 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 ### Phases 0 and 1 of the multiplayer plan — the seams, not the server diff --git a/README.md b/README.md index 38d6089..ca674e4 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,15 @@ train into an occupied Subdivision. Get that wrong and two trains meet at speed. ## 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. -- **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. -- **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 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 @@ -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 `advance(state)` for everything the game does on its own — Mainline movement, collisions, the Stage clock, Superintendent rotation. -- **State is `fold(events)`.** The event log is the source of truth, which is what gives reconnection, - restart recovery and post-game replay from a single decision. +- **The canonical record is the seed plus the intents.** A game is `{ seed, history: Intent[] }` and + `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. - **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 diff --git a/TODO.md b/TODO.md index 82e7886..5bdcda6 100644 --- a/TODO.md +++ b/TODO.md @@ -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 being a `RollingStock` and becomes its own type, or `inboundCleared` discards rather than 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 - event log onto a fresh state throws: the phase driver mutates state directly and emits a - descriptive event afterwards — `newTrainPhase` does `s.trays.set(...)` and then pushes - `trainMadeUp`. Replay works because it re-applies INTENTS (`fromSave`), not because folding - events reconstructs the position. Nothing is broken today, but the claim underwrites - reconnection and restart recovery, which are unbuilt — so it should be either made true or - restated before anything is built on it. +- [x] **`state = fold(events)` was not true, and the docs said it was. Settled: the INTENTS are + canonical.** Measured before deciding — `advance.ts` never calls `reduce`, so **14 of the 46 + event types are never reduced**: the clock, and the entire Mainline phase, which is every train + movement in the game. Folding the log rebuilds a district and not a railroad. Jesse's call, and + the cheap one: the plan never needed fold — persistence is `{ engineVersion, seed, config, + history }` (`multiplayer.md` §10) and the wire carries `Frame`s, not events (D2/D3), so + 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 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 @@ -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). - **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. + - **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 and join. Enough for a private box, probably not enough if `stationmaster.` is pointed at the open internet for long. Note that one-game-at-a-time per person is expected diff --git a/docs/architecture/components.md b/docs/architecture/components.md index 83a8378..9982be0 100644 --- a/docs/architecture/components.md +++ b/docs/architecture/components.md @@ -35,7 +35,7 @@ decision (you are the Superintendent, ruling on your own trains). | Turn arbitration between players | Card catalogue | | Presence, reconnection of others | Track graph and Move legality | | 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 | **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 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 -- **Does:** append-only log to disk; state is `fold(events)`. Powers restart recovery at MVP, and - later reconnection and the replay viewer. +- **Does:** append-only `{ engineVersion, seed, config, history: Intent[] }` to disk. Restart recovery + 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 - **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 - **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 - **Size driver:** trivial in itself, but it is where [`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** - **Where:** Browser · **When:** after a game ends — **pulled forward, built** -- **Does:** read-only projection over the event log, with playback controls. A Node script - precomputes one frame per visible event and writes a self-contained HTML file, so the browser - never runs the engine and there is no bundling or build step. +- **Does:** read-only playback of a stored game, with controls. A Node script replays the saved + intents, precomputes one `Frame` per visible event with its narration, and writes a self-contained + 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)* - **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. @@ -304,7 +305,7 @@ under. [4]apply [5]advance [6]legalActions └───────┼───────┘ ▼ - [8] session host ──► [9] event log + [8] session host ──► [9] intent history │ ▼ [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. | | 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`. | -| 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`. | | 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. | diff --git a/docs/architecture/deployment.md b/docs/architecture/deployment.md index 181f60c..a80b287 100644 --- a/docs/architecture/deployment.md +++ b/docs/architecture/deployment.md @@ -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)) | | 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 | | No outbound network access | The game talks to nobody | | No scheduled work | Nothing in the rules is real-time ([`overview.md`](overview.md)) | diff --git a/docs/architecture/lobby-and-sessions.md b/docs/architecture/lobby-and-sessions.md index 31af061..52e7148 100644 --- a/docs/architecture/lobby-and-sessions.md +++ b/docs/architecture/lobby-and-sessions.md @@ -10,20 +10,31 @@ Written against [`../rules/rules-v0.2.md`](../rules/rules-v0.2.md). ## 1. Identity -No accounts to begin with. A player is a **display name** plus a **session token** the server issues -on join and the client stores locally. +No accounts. Access to the server is a **join secret** (§2); identity within a game is a **display +name** plus a **session token** the server issues on join and the client stores locally. ``` Session token : opaque, unguessable gameId - playerIndex + player : PlayerIndex displayName ``` -The token is what makes reconnection work: it proves "I am the player who was sitting at seat 2," -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. +**It names the PLAYER, not the seat.** Those became different things in v0.4.0, and the difference is +exactly the case this field has to survive: under Employee Rotation (§4) a player changes chairs while +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.` +and `:` 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 [`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 ``` -Lobby.Create { config } → { gameId, gameCode, token } -Lobby.Join { gameCode, displayName } → { token, playerIndex } +Lobby.Create { secret, config } → { gameId, gameCode, token } +Lobby.Join { secret, gameCode, displayName } → { token, player } ``` -A **game code** — short, human-speakable, e.g. `RAIL-4471` — is the whole discovery mechanism. No -matchmaking, no browsing, no public game list. Players are already talking to each other; the code -just needs to survive being read aloud. +**These four messages are the one family with no types behind them**, because the engine has no +concept of a lobby — `intents.ts` starts at `localOps.choose`. Everywhere else, the code is the +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 -end at `Lobby.Start` — the rules give no player special standing during play, and the Superintendent -role rotates independently (§5). +**The join secret gates the door.** Server-wide, set by environment variable, passed out of band by +whoever runs the server (`multiplayer.md` D14). Anyone holding it may create a game and may join any +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 -practical judgment rather than a rules limit: the Division grows by one Office and one Mainline card -per player (§4.3), and every added player lengthens the Superintendent's rotation and the -phase-major waiting. **6 is a sensible cap** for the prototype. +A **game code** — short, human-speakable, e.g. `RAIL-4471` — is the discovery mechanism *inside* the +door. No matchmaking, no browsing, no public game list. Players are already talking to each other; the +code just needs to survive being read aloud. + +**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 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 @@ -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. **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 -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. +D12 takes the Eastern Division Point, and a second roll picks the opening Superintendent. + +**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 -Day, carrying their Revenue and the Fedora with them. Note what this means for the model: a player's -*seat* changes while their *score* follows them, so `Player.index` and Office ownership must be -separable. This is the one optional rule with real structural consequences — worth wiring in from the -start rather than retrofitting. +Day, carrying their Revenue and the Fedora with them. **The model supports this as of v0.4.0**: +offices and districts are keyed by seat, hands and Revenue and the Fedora by player, and `seating[]` +maps between them, so rotating is a one-line operation and every `areaOf(state, player)` caller +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 -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 -if it was their turn. Broadcast a `PlayerDisconnected` event so everyone else can see why nothing is -happening — silence with no explanation is the worst version of this. +if it was their turn. Broadcast a disconnect notice so everyone else can see why nothing is happening +— 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 -the event tail it missed. Because state is `fold(events)` ([`protocol.md`](protocol.md)), catching up -is a replay, not a special case. +**On reconnect:** the client presents its token and the server replies with a **full current view**. +Not an event tail — a returning client needs the position, not the history of how it got there, and +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 -host end the game. An optional **turn timer** is worth having — a lobby setting, off by default, -never part of the rules — that on expiry takes the safest legal action: +host end the game. -| Phase | Timeout action | -| --- | --- | -| Local Operations | `Switch.End` / `Draw.End` — forfeit the remaining action | -| New Train | `NewTrain.PassCar` if legal, else place any legal car | -| 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 | -| Load/Unload | `LoadUnload.End` | +**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 +clearance decision materially affects *other* players' scores, so anything that answers it +automatically changes the game rather than preserving it. Bots fill empty seats **at game start +only** (`multiplayer.md` D8). Two ways out of a permanently-halted game are recorded in `TODO.md` and +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 two outcomes is large, and in Competitive mode a timed-out clearance that causes a wreck could -end the game for everybody (§3.4). +### The turn clock — a stopwatch, not a shot clock -**Do not substitute an AI player.** The Superintendent's clearance decisions materially affect other -players' scores; a bot making them on an absent player's behalf changes the game rather than -preserving it. +**Record how long each player takes on each turn.** Not to enforce anything: to find out where the +game actually goes. "A 4-player game takes an evening" is currently a guess, and the useful version of +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 -Persist the **event log**, not a state snapshot. It is smaller, it is the thing that already exists, -and it makes a mid-game server restart a replay rather than a recovery. +Persist the **intents**, not a state snapshot and not the event log. This section said "event log" +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 } -events : gameId → ordered event list -sessions : token → { gameId, playerIndex, displayName } +games : gameId → { engineVersion, config, seed, status, createdAt, gameCode } +history : gameId → ordered Intent list +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 -covers this comfortably, and so would flat files with an append-only log per game. The constraint -worth honouring is that the **event log is append-only**: rewriting history breaks the one property -that makes replay trustworthy. +covers this comfortably, and so would flat files with an append-only list per game. The constraint +worth honouring is that the **history is append-only**: rewriting it breaks the one property that +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 -that replay feels slow, periodically store a state snapshot with its `eventSeq` and replay forward -from there. Do not build this until it is needed. +**Snapshotting** is an optimisation, not a requirement. If a Campaign game's history grows long +enough that replay feels slow, periodically store a state snapshot with its intent count and replay +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 -a 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. +**Retention.** Finished games **keep their history, seed and config**, because post-game replay is a +wanted capability (see [`overview.md`](overview.md#post-game-replay--a-desired-future-capability)) +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 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 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. --- @@ -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. - **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. -- **Spectators.** Straightforward to add later, since a spectator is just a view with no `private` - section and no ability to send intents — the same shape post-game replay needs. +- **Spectators.** Straightforward to add later: a spectator is a `Frame` with no hand and no ability + 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 in memory. diff --git a/docs/architecture/multiplayer.md b/docs/architecture/multiplayer.md index 250d78b..d7f5a8e 100644 --- a/docs/architecture/multiplayer.md +++ b/docs/architecture/multiplayer.md @@ -31,10 +31,17 @@ follows is assembly. ## 2. What holds, and what moved `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 -render standalone, per-player redacted views. +deterministic engine, two entry points (`applyIntent` / `pump`), events that render standalone, +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` 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 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 -describes. Joining issues it; presenting it *is* the rejoin, because it already names the game and -the seat: +**Seating is decided by §4.4's D12** as of v0.4.1, so `seating` is a real permutation rather than the +identity mapping. Everything "round the table" — acting order, the deal, the Fedora — is 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 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. 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. ### Phase 4 — Lobby, sessions, reconnection · M -16. Join secret; per-game session token; display name. -17. Create/join by game code; bot seats; seating UI showing the west-to-east chain; config locked at - start. -18. Disconnect keeps the seat and announces it; reconnect resumes from `Last-Event-ID`. -19. Optional turn timer, off by default, **denying** clearance on expiry. +17. Join secret; per-game session token naming the **player** (not the seat); display name. +18. Create/join by game code; bot seats; seating UI showing the west-to-east chain; config locked at + start; **2–4 players enforced here**, since the engine enforces nothing. +19. Disconnect keeps the seat and announces it; reconnect replies with a full `Frame`. +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 rejoins where they left off. ### Phase 5 — Multiplayer content · M -20. 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. +21. The 10 Action and 12 Space-use cards; flip `opponentCardsInDeck` in `setup.ts`. +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 answers it fires. ### 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. diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 07306d1..a89a8ca 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -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. -- **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. -An event log that fully determines state is worth building even at this scale. It gives -reconnection (replay to catch up), persistence (store the log, not a snapshot), and debugging (replay -a reported bug exactly) for one design decision. +A recorded, ordered history is worth keeping even at this scale. It gives persistence (store the +history, not a snapshot) and debugging (replay 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 @@ -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 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 -stored seed. Replay is therefore a **read-only projection over the existing log** — no new rules-engine -surface, no second code path, nothing the server has to do differently during play. +A game is already reconstructible from `{ seed, history: Intent[] }`, and all randomness derives from +that stored seed. Replay is therefore a **read-only re-run of the stored intents** — no new +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: @@ -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: - **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 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. diff --git a/docs/architecture/protocol.md b/docs/architecture/protocol.md index 289f5c0..07bc000 100644 --- a/docs/architecture/protocol.md +++ b/docs/architecture/protocol.md @@ -11,7 +11,7 @@ that live nowhere else. | Family | Direction | Authority | | --- | --- | --- | | **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)` | | **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 -Events are the authoritative history: `state = fold(events)`, which buys reconnection, persistence and -post-game replay for one design decision. +**Events narrate; they do not reconstruct.** This section claimed `state = fold(events)` until +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 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 between their Limits (§10) — and getting it wrong misattributes a −5 and, in Competitive, feeds a floor that ends the game. -- **Events are not what goes over the wire.** The server folds them and pushes the resulting `Frame` - (`multiplayer.md` D2/D3). They are the store and the replay format, not the protocol. +- **Events are not what goes over the wire.** The server applies the intent and pushes the resulting + `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. --- diff --git a/docs/design.md b/docs/design.md index 9faf7ad..40d917c 100644 --- a/docs/design.md +++ b/docs/design.md @@ -49,73 +49,53 @@ must do. ## Current status -Rules formalized, card faces specified, architecture documented, and the rules engine built and -simulated. Eleven of thirteen gaps are closed; **Gap 12 (balance variance) and Gap 13 (deck scaling) -remain open**, both with data behind them. +**v0.4.1.** Rules formalized, card faces specified, architecture documented, and the game playable +solitaire in a browser. See [`../CHANGELOG.md`](../CHANGELOG.md) for what each version changed and +[`../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 -by reading the PDFs. None arises in tabletop play, where a person simply does the sensible thing. +**What is built.** The rules engine, the developer bot, the balance harness, the replay viewer and +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 per Day — but **inbound work bypasses them**, which is where the game's variance comes from. See `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 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 -> [`rules/implications.md`](rules/implications.md) §1b for exactly what is implemented. The deck is -> 115 cards (93 in solitaire), Mainline cards have terrain and crossing times, and the balance -> 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). +Running alongside, and independent of all of it: **print-and-play components.** `card-reference.md` +specifies every card face, so layout and art are the only remaining work before a table playtest — +which answers the one question simulation cannot, whether it is fun. Run the harness with `node src/sim/harness.ts [games] [length]`. diff --git a/package.json b/package.json index d55a87f..b95c6f7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "station-master", - "version": "0.4.0", + "version": "0.4.1", "private": true, "type": "module", "description": "Station Master — a railroad operations game", diff --git a/src/engine/advance.ts b/src/engine/advance.ts index 3f3225e..e3a6646 100644 --- a/src/engine/advance.ts +++ b/src/engine/advance.ts @@ -39,7 +39,7 @@ import type { GameEvent } from './events.ts'; import { areaAtSeat, areaOf, trainNeedingCars } from './apply.ts'; import { legalActions } from './legal.ts'; import type { CrewTray, DivisionNode, GameState, PlayerIndex, RollingStock, SeatIndex, TrayId } from './state.ts'; -import { coordKey, freshTurns, playerAtSeat, subdivisions, totalRevenue, turnOf } from './state.ts'; +import { coordKey, freshTurns, playerAtSeat, playerLeftOf, subdivisions, totalRevenue, turnOf } from './state.ts'; export type AdvanceResult = { events: GameEvent[]; @@ -147,7 +147,7 @@ function playerPhase( } 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'] { @@ -972,7 +972,7 @@ function retireTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult { // §5 — the Fedora passes every three Stages: shift changes at Stages 3, 6, 9 and 12. 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 }); } diff --git a/src/engine/apply.ts b/src/engine/apply.ts index 27477c6..87c1497 100644 --- a/src/engine/apply.ts +++ b/src/engine/apply.ts @@ -9,8 +9,8 @@ * - `execute` reads state and emits events; it never mutates either. * - `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 - * recovery work. It also lets component 6 (legalActions) call these very same `check` functions, + * That keeps every INTENT-driven change reducible by construction. Replay and restart recovery run + * 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. */ @@ -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 { diff --git a/src/engine/events.ts b/src/engine/events.ts index a4745c6..8dd681c 100644 --- a/src/engine/events.ts +++ b/src/engine/events.ts @@ -1,9 +1,18 @@ /** * Events — protocol.md §3. * - * An event is a FACT. Events are ordered, append-only, and fully determine state: - * `state = fold(events)`. That one property gives reconnection, restart recovery and post-game - * replay together. + * An event is a FACT: ordered, append-only, and standalone. Events NARRATE the game — they drive the + * log, the sounds and the replay's captions. + * + * 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, * not just an id the renderer has to resolve against live state — otherwise a replay viewer has to diff --git a/src/engine/setup.ts b/src/engine/setup.ts index 2d51ed9..6884334 100644 --- a/src/engine/setup.ts +++ b/src/engine/setup.ts @@ -256,14 +256,35 @@ export function createGame(opts: SetupOptions): GameState { // seat/player split behaviour-neutral. Employee Rotation would rotate this array and nothing else. const officeAreas = new Map(); 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. // Both rolls are drawn even in solitaire so the RNG stream stays identical across player counts. const divisionRolls = players.map(() => rng.d12()); const superRolls = players.map(() => rng.d12()); 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. @@ -297,8 +318,11 @@ export function createGame(opts: SetupOptions): GameState { const hands = new Map(); let trackCursor = 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++) { - const p = (superintendent + i) % playerCount; + const p = seating[(superSeat + i) % playerCount]!; hands.set(p, [ ...trackPile.slice(trackCursor, trackCursor + OPENING_TRACK), ...otherPile.slice(otherCursor, otherCursor + OPENING_OTHER), @@ -340,6 +364,7 @@ export function createGame(opts: SetupOptions): GameState { rngState: rng.getState(), players, seating, + openingRolls: { division: divisionRolls, superintendent: superRolls }, division: { nodes: buildDivision(playerCount, rng) }, officeAreas, trays: new Map(), diff --git a/src/engine/state.ts b/src/engine/state.ts index b4cc3be..ad52f24 100644 --- a/src/engine/state.ts +++ b/src/engine/state.ts @@ -543,12 +543,19 @@ export type GameState = { rngState: number; 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 - * behaviour-neutral. Employee Rotation would rotate this array and nothing else. + * Decided at setup by §4.4's D12 (`openingRolls.division`) — a real permutation, not the identity, + * except in solitaire where one player means one seat. Employee Rotation would rotate this array + * and nothing else. */ 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; officeAreas: Map; trays: Map; @@ -623,6 +630,22 @@ export function playerAtSeat(state: GameState, seat: SeatIndex): PlayerIndex { } /** 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 { const seat = state.seating.indexOf(player); if (seat < 0) throw new Error(`player ${player} is not seated`); diff --git a/src/sim/bot.ts b/src/sim/bot.ts index 42a2816..813f79a 100644 --- a/src/sim/bot.ts +++ b/src/sim/bot.ts @@ -1691,7 +1691,7 @@ export type PlayOutcome = { trainsScheduled: number; cardsPlayed: number; 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[]; /** Every intent the bot actually submitted, for action-mix analysis. */ intents: Intent['type'][]; diff --git a/src/sim/stats.ts b/src/sim/stats.ts index 239f616..acd38cf 100644 --- a/src/sim/stats.ts +++ b/src/sim/stats.ts @@ -1,9 +1,10 @@ /** * End-of-game statistics. * - * Dev-side. Compiles a readable account of how a game actually went, from the event log. Because - * `state = fold(events)`, the log is the complete record — nothing needs instrumenting in the - * engine to produce any of this. + * Dev-side. Compiles a readable account of how a game actually went, from the event log. Every event + * the game emits is in there, so nothing needs instrumenting in the 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: * diff --git a/src/sim/view.ts b/src/sim/view.ts index 3e77738..6444210 100644 --- a/src/sim/view.ts +++ b/src/sim/view.ts @@ -289,8 +289,14 @@ export type Frame = { option: 'switch' | 'draw' | 'freightAgent' | null; status: GameState['status']; 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`. */ handCount: number; lines: { text: string; tone: string }[]; @@ -1042,6 +1048,7 @@ export function snapshot( outcome: s.outcome, players: s.players.map((p) => ({ index: p.index, + seat: seatOf(s, p.index), name: p.name, revenue: p.revenue, hand: (s.decks.hands.get(p.index) ?? []).length, diff --git a/src/web/game.ts b/src/web/game.ts index 0963436..fcc1ca1 100644 --- a/src/web/game.ts +++ b/src/web/game.ts @@ -17,9 +17,10 @@ * legal would eventually disagree with `check`, and the failure mode is a UI that offers an illegal * 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 - * 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. + * SAVING. The intents ARE the game — the RNG is seeded and `applyIntent` is deterministic — so a save + * 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. (Not the event log: + * folding events does not rebuild a game — `protocol.md` §3.) */ import { pump } from '../engine/advance.ts'; diff --git a/test/events.test.ts b/test/events.test.ts new file mode 100644 index 0000000..869c1c0 --- /dev/null +++ b/test/events.test.ts @@ -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 { + 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 { + 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']); + }); +}); diff --git a/test/multiplayer.test.ts b/test/multiplayer.test.ts index 8c5a21f..d71ccd8 100644 --- a/test/multiplayer.test.ts +++ b/test/multiplayer.test.ts @@ -11,7 +11,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; 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 { createGame } from '../src/engine/setup.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); s.trays.set(id, { 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); } @@ -93,8 +93,11 @@ describe('multi-player games run at all', () => { for (const players of [2, 3, 4]) { const s = game(players); assert.equal(s.officeAreas.size, players, `${players}p office areas`); - for (let p = 0; p < players; p++) { - assert.equal(areaOf(s, p).seat, p, 'an Office Area is owned by the wrong seat'); + // Every seat is occupied by exactly one player, and that player's district is that seat's. + // 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. 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. * * `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 - * Fedora each time a decision is asked for. + * 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: §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 s = game(players); @@ -116,7 +121,7 @@ describe('the Fedora goes round the table', () => { const spy = { name: 'spy', choose(st: GameState, p: PlayerIndex, opts: Parameters[2]) { - const who = st.clock.superintendent; + const who = seatOf(st, st.clock.superintendent); if (seen[seen.length - 1] !== who) seen.push(who); return developerBot.choose(st, p, opts); }, @@ -183,13 +188,16 @@ describe('Subdivisions are split by whoever is a Control Point', () => { const s = game(4); 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); 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. - const officeIndex = (owner: number): number => - s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === owner); + const officeIndex = (seat: number): number => + s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === seat); const all = split.flat(); assert.ok(!all.includes(officeIndex(1)), 'the Control Point is still inside a Subdivision'); 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', () => { 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. 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', () => { - it('starts with the identity mapping, so the split changes nothing today', () => { - // The whole seat/player split is behaviour-neutral until something rotates `seating`. This is - // what makes that claim checkable rather than asserted. - for (const players of [1, 2, 3, 4]) { - const s = players === 1 - ? createGame({ id: 's', seed: 4242, config: { ...competitive, mode: 'solitaire' }, playerNames: ['a'] }) - : game(players); - assert.deepEqual(s.seating, [...Array(players).keys()], `${players}p seating is not the identity`); + it('seats everyone exactly once, and seats them by the §4.4 roll', () => { + /** + * `seating` was the identity mapping until §4.4's D12 was wired up, which meant the whole + * seat/player distinction was untested at runtime — every mix-up of the two was silently + * correct. It is a real permutation now, and this is what says so. + * + * Solitaire is the exception and must stay one: with a single player there is one seat, the + * 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++) { - assert.equal(seatOf(s, p), p); - assert.equal(playerAtSeat(s, p), p); - assert.equal(areaOf(s, p).seat, p); + assert.equal(playerAtSeat(s, seatOf(s, p)), p, 'seatOf and playerAtSeat disagree'); + assert.equal(areaOf(s, p).seat, seatOf(s, p), 'a player is looking at the wrong district'); + } + } + + 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 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. - for (let seat = 0; seat < 3; seat++) areaOf(s, seat).tier = (['depot', 'station', 'terminal'] as const)[seat]!; + // Mark each Office so we can see which one a player is looking at. BY SEAT — the Offices are + // 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; - 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. - s.seating = [2, 0, 1]; + // Everyone shuffles one chair along: whoever was at seat n is now at seat n+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(tierOf), ['station', 'terminal', 'depot'], 'the Offices moved with them'); - assert.equal(playerAtSeat(s, 0), 2, 'seat 0 should now be occupied by player 2'); + 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), + 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. 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. */ 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()); + const occupant = playerAtSeat(s, 1); const withImpediments = (): PlayerIndex[] => [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. - s.seating = [2, 0, 1]; - assert.deepEqual(withImpediments(), [0], 'the impediment did not follow the chair'); + // Everyone shuffles one chair along, so seat 1 changes hands. + const rotated = [...s.seating]; + 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( [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', ); }); @@ -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 // and it would be easy for a shared reference to make every district the same district. const s = game(3); - const mine = areaOf(s, 1); - const sizeBefore = s.officeAreas.get(2)!.grid.size; + const mine = areaAtSeat(s, 1); + const sizeBefore = areaAtSeat(s, 2).grid.size; mine.grid.set(coordKey({ row: mine.runningRow - 1, col: 0 }), { geometry: { kind: 'track', geometry: 'straight' }, 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.notEqual(areaOf(s, 1), areaOf(s, 2), 'two seats share one Office Area object'); + assert.equal(areaAtSeat(s, 2).grid.size, sizeBefore, "one seat's track appeared in another's district"); + assert.notEqual(areaAtSeat(s, 1), areaAtSeat(s, 2), 'two seats share one Office Area object'); }); });