v0.4.1 - bug fixes. initial d12 rolls determine what player in which seat.
This commit is contained in:
+118
@@ -21,6 +21,124 @@ page as `v0.1.0 · <sha> · <date>`, 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.<domain>` is
|
||||
pointed at the open internet for long. Note that one-game-at-a-time per person is expected
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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)) |
|
||||
|
||||
@@ -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.<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
|
||||
[`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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+38
-58
@@ -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]`.
|
||||
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
+7
-3
@@ -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 {
|
||||
|
||||
+12
-3
@@ -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
|
||||
|
||||
+29
-4
@@ -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<SeatIndex, OfficeArea>();
|
||||
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<PlayerIndex, CardId[]>();
|
||||
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(),
|
||||
|
||||
+26
-3
@@ -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<SeatIndex, OfficeArea>;
|
||||
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. */
|
||||
/**
|
||||
* 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`);
|
||||
|
||||
+1
-1
@@ -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'][];
|
||||
|
||||
+4
-3
@@ -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:
|
||||
*
|
||||
|
||||
+9
-2
@@ -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,
|
||||
|
||||
+4
-3
@@ -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';
|
||||
|
||||
@@ -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
@@ -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<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);
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user