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

This commit is contained in:
Jesse
2026-08-13 15:28:53 -04:00
parent 49f8504b05
commit a7221dcf20
22 changed files with 736 additions and 237 deletions
+118
View File
@@ -21,6 +21,124 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
## Unreleased
## 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
+12 -5
View File
@@ -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
+23 -7
View File
@@ -127,13 +127,18 @@ Ordered within each section by how much it is currently costing us.
can be tuned until this is settled**. The fix is a decision, not a patch: either a load stops
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
+11 -10
View File
@@ -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. |
+1 -1
View File
@@ -16,7 +16,7 @@ The requirements are modest, which is what makes both paths viable:
| --- | --- |
| One long-running process | Active games are held in memory ([`overview.md`](overview.md)) |
| 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)) |
+152 -61
View File
@@ -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.
+39 -15
View File
@@ -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.
+18 -8
View File
@@ -67,12 +67,18 @@ Three flows, and keeping them distinct is what keeps the implementation tractabl
```
- **Intents** are what a player wants to do. They are proposals; they can be rejected.
- **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.
+14 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -256,14 +256,35 @@ export function createGame(opts: SetupOptions): GameState {
// seat/player split behaviour-neutral. Employee Rotation would rotate this array and nothing else.
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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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';
+128
View File
@@ -0,0 +1,128 @@
/**
* What the event log is, and what it is not.
*
* The README, four architecture documents and six source comments all claimed `state = fold(events)`
* until v0.4.0. It was never true, and the claim was load-bearing — it was the stated justification
* for reconnection, restart recovery and persistence, none of which were built yet. This file makes
* the real shape checkable so the claim cannot quietly come back.
*
* The decision (`docs/architecture/protocol.md` §3): **the intents are canonical.** A game is
* `{ seed, history: Intent[] }`, `fromSave` replays it exactly, and events narrate.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fromSave, newGame, submit, toSave, view } from '../src/web/game.ts';
import { actionGroups, currentActor } from '../src/web/game.ts';
const src = (rel: string): string => readFileSync(join(import.meta.dirname, '..', 'src', rel), 'utf8');
/** Every `type: 'x'` in the `GameEvent` union. */
function eventTypes(): Set<string> {
const text = src('engine/events.ts');
const union = text.slice(text.indexOf('export type GameEvent ='));
return new Set([...union.matchAll(/type: '([a-zA-Z]+)'/g)].map((m) => m[1]!));
}
/** Every `case 'x':` inside `reduce`. */
function reducedTypes(): Set<string> {
const text = src('engine/apply.ts');
const body = text.slice(text.indexOf('export function reduce'));
return new Set([...body.matchAll(/case '([a-zA-Z]+)':/g)].map((m) => m[1]!));
}
/**
* The event types the reducer does not handle, as of v0.4.0.
*
* Every one is emitted by the phase driver in `advance.ts`, which mutates state and then describes
* what it did. Read the list: it is the clock, plus the entire Mainline phase — which is to say every
* train movement in the game. That is why folding the log rebuilds a district and not a railroad.
*/
const KNOWN_UNREDUCED = [
'actorChanged',
'carPassed',
'clearanceRequested',
'dispatchBonusUsed',
'phaseBegan',
'stageBegan',
'trainArrived',
'trainCompleted',
'trainDiverted',
'trainHeld',
'trainHighballed',
'trainMadeUp',
'trainStoodStill',
'trainsDestroyed',
];
describe('the event log narrates but does not reconstruct', () => {
it('has exactly the unreduced event types it is documented to have', () => {
/**
* A CHANGE-DETECTOR ON PURPOSE. If this fails because the list shrank, someone has made the
* phase driver reduce — good, and the docs in `protocol.md` §3, `events.ts` and the README now
* understate the engine and should be corrected in the same change. If it fails because the list
* GREW, a new event was added on the mutate-then-describe path, which is worth knowing before it
* becomes another thing the log cannot rebuild.
*/
const all = eventTypes();
const reduced = reducedTypes();
const unreduced = [...all].filter((t) => !reduced.has(t)).sort();
assert.deepEqual(
unreduced,
KNOWN_UNREDUCED,
'the set of events the reducer ignores has changed — see the comment above KNOWN_UNREDUCED',
);
});
it('never folds events in the phase driver, which is what makes the above true', () => {
// `advance.ts` mutating directly is the whole mechanism. If it ever starts calling `reduce`,
// the claim becomes recoverable and this file should be rewritten rather than relaxed.
assert.doesNotMatch(
src('engine/advance.ts'),
/\breduce\(s[,)]/,
'the phase driver now folds events — reconsider the canonical-record decision',
);
});
});
describe('the intents are what reconstructs a game', () => {
it('replays a partly-played game to exactly the same position', () => {
// The property that actually holds, stated as a test rather than as a comment. This is what
// save, share, undo, restart recovery and post-game replay all rest on.
const game = newGame(31337);
for (let i = 0; i < 120; i++) {
if (currentActor(game) === null) break;
const { options } = actionGroups(game);
if (options.length === 0) break;
if (!submit(game, options[0]!)) break;
}
assert.ok(game.history.length > 20, 'the driver did not play far enough to be a real test');
const back = fromSave(toSave(game));
assert.deepEqual(view(back).cells, view(game).cells, 'the board differs after replay');
assert.deepEqual(
back.state.players.map((p) => p.revenue),
game.state.players.map((p) => p.revenue),
'the score differs after replay',
);
assert.equal(back.state.clock.day, game.state.clock.day);
assert.equal(back.state.clock.stage, game.state.clock.stage);
assert.equal(back.state.clock.phase, game.state.clock.phase);
// The trains are the part folding the log would have lost, so check them specifically.
assert.deepEqual(
[...back.state.trays.keys()].sort(),
[...game.state.trays.keys()].sort(),
'the crews differ after replay',
);
});
it('carries no state in the save beyond the seed and the intents', () => {
// If anything else ever creeps into `Save`, the claim above weakens: the game would no longer be
// reconstructible from decisions alone, and persistence would have a schema to migrate.
const game = newGame(7);
assert.deepEqual(Object.keys(toSave(game)).sort(), ['history', 'seed']);
});
});
+86 -41
View File
@@ -11,7 +11,7 @@ import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { 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');
});
});