diff --git a/CHANGELOG.md b/CHANGELOG.md index cee5a6b..629dd7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,85 @@ page as `v0.1.0 · · `, so what is deployed can always be identifie ## Unreleased +## 0.4.0 — 2026-08-13 + +### Phases 0 and 1 of the multiplayer plan — the seams, not the server + +`docs/architecture/multiplayer.md` is the plan; this is its first two phases. Nothing a player can see +changed, and that is the exit criterion: the whole point is that solitaire is byte-for-byte the same +game while the code underneath it stops assuming there is only ever one of you. + +**Seat and player are now different things.** They were the same integer everywhere, which is correct +today and wrong the moment Employee Rotation moves someone to a different chair. Offices and districts +are keyed by SEAT; hands, Revenue and the Fedora belong to the PLAYER. `seating[]`, `seatOf` and +`playerAtSeat` make the mapping explicit — it is still the identity mapping, so nothing moves yet. + +The split immediately found a real bug: `awardDeparture` was indexing `s.players` with a seat. Correct +under the identity mapping, silently paying the wrong railroad under rotation, and unfalsifiable in +solitaire. That is what the change is for. + +**Per-player turn state.** `turn: TurnState` became `turns: Map`, one per +player at phase entry, read through `turnOf(s, player)` at ~50 sites. **Behaviour-neutral by +construction** — the phase cursor still walks one player at a time, and every existing test passed +unchanged. It is done now rather than in Phase 2 because it is a wide, mechanical change to the engine +that costs almost nothing today and would be a rewrite once a wire format depends on the shape. What +it buys later is players acting in parallel where the rules allow it, which is where the latency in a +turn-based game over the internet actually lives. + +**The page renders from `Frame` + `Menu` alone.** `main.ts` had eleven reads through into `GameState` +— the deck, the RNG, other players' hands — each one a thing a server would never send. `Frame` grew +`option`, `status`, `outcome`, `players[]` and `handCount` to cover them. A test now reads `main.ts` +and fails if a reader comes back, because the cheap moment to catch that is now and not in Phase 2. + +**A `Session` between the page and the game.** `LocalSession` runs the engine in the browser exactly +as before; a `RemoteSession` will hold no authoritative state at all — it cannot, having neither the +deck order nor the other hands. So the interface is deliberately the smaller of the two, and what only +a local session can do (undo, local save, dealing a new game) is declared in `capabilities`, which the +page reads to hide those controls rather than calling them and failing. `submit` is async even though +the local one answers immediately: a page written against a synchronous submit would have to be +rewritten for the server. + +`test/session.test.ts` is the proof — 13 tests, the first of which plays seed 77 to a finish through +both routes and asserts the boards and the histories are identical. + +**Docs.** `protocol.md` was written from the rules before the engine existed and had become a second, +drifting copy of `intents.ts` and `events.ts`; it now points at them and keeps only what the types +cannot say — what is deliberately *not* an intent, the two loops a client must not flatten, and the +redaction surface. Three architecture documents still said WebSocket where D5 chose SSE + POST; fixed. + +**Then a review found the seat/player audit was half done, and it was right.** Two flagged sites, and +sweeping for the pattern found six more. All of them are correct today and all of them break the +moment Employee Rotation lands, which is exactly the failure mode the split was supposed to end. + +*Keyed an Office Area by player when `officeAreas` is keyed by seat:* `refusesThisOffice` +(`apply.ts` — the Crack Limited's terminals-only rule), `spendDispatchBonus` (`advance.ts` — the +Fedora is held by a PLAYER, the Telegraph is installed in a SEAT), `impediments` (`narrate.ts`), and +two district lookups in the bot. `adTrackCount(s, player)` in `narrate.ts` was passing a player into +a `SeatIndex` parameter — the type said seat and the caller said player, and TypeScript cannot tell +two `number` aliases apart, which is precisely why this needs a test rather than a type. + +*Read seat 0 regardless of the viewer:* the bot's `takingRank` ranked a face-up Office card against +**seat 0's** Office tier for every player, so in a multi-player bot game everyone chased player 0's +upgrade. `stats.ts` and `save-replay.ts` also index seat 0, which is correct — they are solitaire +summaries — and now say so through `areaAtSeat` instead of looking like the same bug. + +**And `Frame` was seat-scoped in the board and the hand but not in four fields beside them.** +`revenue`, `moves`, `blocked` and the pace note all still reported player 0. A player would have been +shown someone else's score, someone else's Moves remaining, and someone else's jammed facilities — +that last one a list of squares that are not on the board they are looking at. Worse, the crew lookup +was keyed by `row,col` alone while every district shares an origin, so a crew standing at (0, 1) in +one Office Area was drawn at (0, 1) on **every** player's board. + +Five new tests, all of which fail against the previous commit: per-seat Revenue/pace/impediments, +crews staying on their own board, a full 3-player game played with `seating` rotated before the first +move, impediments following a player to their new chair, and a source-level guard that fails if +anything outside `state.ts`/`apply.ts` touches `officeAreas` directly — the class, rather than the +eight instances of it found by hand. + +Bot unchanged at 7.0 mean over 200 solitaire Standard games (the bot fixes are behaviour-neutral with +one player), and all four published replays still play to the end — which, for a change of this width, +is the whole report. + ## 0.3.1 — 2026-08-13 ### Groundwork for multiplayer diff --git a/README.md b/README.md index 85748b5..38d6089 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ train into an occupied Subdivision. Get that wrong and two trains meet at speed. ## Status -**v0.3.1 — solitaire is playable in a browser.** The whole game runs client-side: the engine is pure, +**v0.4.0 — 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. @@ -18,9 +18,10 @@ imports nothing outside itself, and never touches `Math.random`, so a static hos - **Architecture** — six documents, including a 20-component build 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** — multiplayer (the engine runs 2–5 player games and the bot plays them, but there is - no server, no turn submission and no per-player view), the 22 opponent-directed cards, and real - audio. +- **Not built** — the multiplayer server (Phases 0 and 1 of the plan are done: seat and player are + separate, turn state is per player, and the page talks to a `Session` rather than to the engine, so + a remote one drops in without the page changing — but there is no server, no turn submission and no + per-player push), the 22 opponent-directed cards, and real audio. Balance is *not* where it should be: the developer bot averages 7.0 Revenue against a target of 20 — of which ~5.4 is the "one Revenue per train that clears your section" rule, so the working freight diff --git a/TODO.md b/TODO.md index 1098e84..82e7886 100644 --- a/TODO.md +++ b/TODO.md @@ -469,6 +469,24 @@ target is settled and freight carries its intended share. engine currently has no per-player turn within the New Train phase, so this is unbuilt rather than wrong. +- [ ] **MULTIPLAYER — three things deliberately deferred while planning the server.** Decisions and + reasoning are in `docs/architecture/multiplayer.md` §11; these are the ones left open. + - **Bots should take minimally damaging, defensive actions when a player steps away**, so a + game is not permanently halted. Deliberately NOT automatic today: a turn timer forfeiting is + different from a bot competing, and the clearance ruling is the one decision that changes + 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. + - **Revisit the join secret** (D14). One server-wide secret, passed out of band, gates create + and join. Enough for a private box, probably not enough if `stationmaster.` is + pointed at the open internet for long. Note that one-game-at-a-time per person is expected + usage and deliberately NOT enforced — enforcing it needs cross-game state whose only job is + deciding when to release someone, and getting that wrong locks a player out. +- [ ] **WHY DOES A 4-PLAYER COMPETITIVE GAME END AFTER ~16 STAGES OF A POSSIBLE 60?** Measured while + sizing multiplayer: 8 games, all reaching Day 5, but only ~16 distinct (day, stage) pairs each + and ~355 intents. Most likely the collision or revenue floor (§3.4) firing early, which would + make a competitive game about an hour rather than four. Worth knowing whether that is the + design working or a balance bug — it decides what a lobby should tell players about length. - [ ] **THE 22 OPPONENT-DIRECTED CARDS — 10 Action, 12 Space-use — ARE OUT OF EVERY DECK UNTIL THEY ARE BUILT.** Jesse's call. They were already cut from solitaire (Q6, no legal target with one player); they are now cut from the competitive deck too, because `checkPlay` answers both @@ -478,8 +496,13 @@ target is settled and freight carries its intended share. **three Enhancements are waiting on them**: Facing Point Locks, Water Column and Overpass are wired and read, and fire only against these cards. Until then those three are dormant by design rather than broken. -- [ ] **Multiplayer proper.** The engine runs 2–5 player games and the bot plays them, but there is no - server, no turn submission, and no per-player view. +- [ ] **Multiplayer proper — Phases 0 and 1 done (v0.4.0), Phases 2–6 to go.** The full plan is + `docs/architecture/multiplayer.md` §12. The engine now has seat/player separation and + per-player turn state, the page renders from `Frame` + `Menu` alone and talks to a `Session` + rather than to the engine — so a `RemoteSession` can be dropped in without the page changing. + Still no server, no turn submission and no per-player push: that is Phase 2, and it is + deliberately held until the two provisional rules have been playtested, because a rule change + after the wire format is live is much more expensive than one before it. --- diff --git a/docs/architecture/components.md b/docs/architecture/components.md index c1ef952..83a8378 100644 --- a/docs/architecture/components.md +++ b/docs/architecture/components.md @@ -161,8 +161,8 @@ is what makes the whole system testable without a server. **11. Transport** - **Where:** Server · **When:** per connection; pushes on every event -- **Does:** HTTP for lobby operations, WebSocket for the ordered event stream, static asset serving. - Bidirectional — most traffic is server → client. +- **Does:** HTTP POST for lobby operations and intents, SSE for the ordered push stream, static asset + serving ([`multiplayer.md`](multiplayer.md) D5). Most traffic is server → client. - **MVP: S** · **Final: M** · ~150 → ~400 LOC - **Size driver:** small at MVP because one client needs no fan-out. Grows with broadcast, backpressure and reconnect-with-replay. diff --git a/docs/architecture/deployment.md b/docs/architecture/deployment.md index eb93954..181f60c 100644 --- a/docs/architecture/deployment.md +++ b/docs/architecture/deployment.md @@ -15,7 +15,7 @@ The requirements are modest, which is what makes both paths viable: | Need | Why | | --- | --- | | One long-running process | Active games are held in memory ([`overview.md`](overview.md)) | -| HTTP + WebSocket on one port | Lobby over HTTP, game stream over WebSocket ([`protocol.md`](protocol.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)) | | Static asset serving | The browser client | | No outbound network access | The game talks to nobody | @@ -59,7 +59,7 @@ network access to that StartOS box. How they get it is the user's configuration something the application chooses or should claim. **One thing the architecture must respect.** A packaged service should not assume it is reachable at -a fixed, publicly-routable URL. Anything that bakes an origin into the client — absolute WebSocket +a fixed, publicly-routable URL. Anything that bakes an origin into the client — absolute stream URLs, hard-coded hostnames in links, CORS allow-lists pinned to one domain — will break. Serve the client from the same origin as the API and use relative URLs throughout. This costs nothing on a plain web host and is required on StartOS, so it should simply be the rule. diff --git a/docs/architecture/multiplayer.md b/docs/architecture/multiplayer.md new file mode 100644 index 0000000..250d78b --- /dev/null +++ b/docs/architecture/multiplayer.md @@ -0,0 +1,432 @@ +# Multiplayer — design and build plan + +How Station Master becomes a multiplayer game with an authoritative server, **as a layer added on top +of what exists**. Solitaire keeps working exactly as it does today: opened from a static host, played +entirely in the browser, no server involved at any point. + +This is a plan, not an implementation. Written against the engine as built (v0.3.1). It reconciles +[`overview.md`](overview.md), [`protocol.md`](protocol.md), +[`lobby-and-sessions.md`](lobby-and-sessions.md) and [`deployment.md`](deployment.md) — all written +*before* the engine existed — with what was actually built, and with the decisions reviewed and +settled in §11. + +--- + +## 1. The constraint that shapes everything + +> Solitaire still plays the same. No server needed. + +That decides the architecture: + +- The engine stays **pure and browser-runnable**. No server-only dependency enters `src/engine/`. +- The client runs in **two modes** without forking: authoritative-local (solitaire, today's + behaviour) and view-only-remote (multiplayer). +- The static deploy keeps working. A server is an *additional* way to run the game. + +The existing architecture anticipated nearly all of this and the engine was built to it. Most of what +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. + +Three things moved: + +**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). + +**`protocol.md`'s message vocabulary was out of date; fixed in Phase 0.** It had been written from +the rules before the engine existed, and the real `Intent` union diverged — different names +(`switch.move`, not `Switch.Move`), different shapes (`dropCars` grew `fromNose`; `card.play` grew +`variant` and `node`), and intents that did not exist then (`switch.sortConsist`, +`newTrain.secondSection`, `maneuver.*`, `redFlag.*`), plus 40-odd rejection codes against a listed +20. **The real types are the protocol**, so `protocol.md` now points at `intents.ts` and `events.ts` +and keeps only what the types cannot say. + +**The client already renders from a projection.** `snapshot()` produces a `Frame`, `actionMenu()` a +`Menu`, and the renderers consume only those. This is why the multiplayer client is cheap. + +--- + +## 3. Measurements this plan rests on + +Taken from 8 four-player bot games and 12 solitaire games, so the sizing is not guesswork. + +| | value | +| --- | --- | +| Intents per game (4 players) | ~355, over ~16 Stages | +| Intents per player per game | ~89 | +| Intents per Stage | 22 — about 5.5 yours, 16.7 watched | +| Phase split | Local Ops 78%, Load/Unload 19%, New Train 3% | +| A turn: switch / draw / freight agent | 5.8 / 4.3 / 2.0 intents | +| Frame | 9.3 KB mean; **2.9 KB** with the board delta'd | +| Menu | 2.2 KB mean, 9.8 KB max | +| Events per intent | 3.3, ~160 B | +| Push rate | ~0.4 per second across a whole game | + +Two consequences worth stating plainly: + +**Latency is not the problem.** Watching does not block — pushes arrive asynchronously. The only +latency a player feels is on their own ~89 actions: **1.7 seconds across an entire game** on a LAN. +Nothing here is a performance decision. + +**Idle time is the problem.** With four players you watch ~17 of 22 intents per Stage. That is a +turn-order property of the rules, not of the implementation — see §11 D19 for what is being kept +open about it. + +> **Open, and not a plan decision:** a 4-player competitive game ends after **~16 Stages of a +> possible 60**, well before the 5-day limit. Most likely the collision or revenue floor (§3.4) +> firing early. Worth understanding before building a lobby around game length — it may be a balance +> bug rather than intent. + +--- + +## 4. The structural idea: a Session boundary + +Today `main.ts` calls `submit(game, intent)`, which applies in-process and re-renders. Introduce one +interface between the UI and the game: + +``` +Session + view() -> Frame what to draw + menu() -> Menu what may be done + submit(intent) -> Promise propose an action + subscribe(cb) -> unsubscribe "something changed, re-render" + capabilities -> { undo, saveLocal, newGame } +``` + +- **`LocalSession`** wraps today's `Game`: applies through the engine, pumps `advance`, keeps + `history`, supports undo and `localStorage`. **This is solitaire, unchanged.** +- **`RemoteSession`** holds no authoritative state. Posts intents, receives `Frame` and `Menu`, + re-renders on push. Undo and local save are absent from `capabilities`, so the UI hides those + controls rather than failing when pressed. + +The client cannot keep authoritative state in remote mode even if it wanted to: it has neither the +deck order nor the other players' hands. + +--- + +## 5. What the client must stop doing + +`main.ts` reaches into `game.state` in **11 places**. Five new `Frame` fields remove all of them: + +| Reads today | Fix | +| --- | --- | +| `clock.day`, `clock.stage`, `clock.phase` | already on `Frame` | +| `turn.option` | **add** — and under §6 it becomes *your* turn, not *the* turn | +| `status`, `outcome` | **add** | +| `players` (names and revenues of every seat) | **add** — the scoreboard is public | +| `decks.hands.get(actor).length` | **add** as `handCount`, plus per-seat counts | + +Everything else it draws already comes from `Frame`/`Menu`. + +**Solitaire-only, gated by `capabilities`:** undo (other players have seen the result), `localStorage` +save/restore, new-game-by-seed-in-URL. + +--- + +## 6. Per-player turn state + +`s.turn` is a **single** `TurnState` — one `option`, one `movesRemaining`, one `freightWorked` — read +in 50 places, with a phase driver that walks players one at a time calling `freshTurn()` as it goes. + +**Phase 0 makes it per-player: `turns: Map`, with a turn created for every +player at phase entry.** This is deliberately **behaviour-neutral** — the cursor still advances one +player at a time, so play is identical and every existing test still describes the same game. + +It is done now because the engine is least encumbered now, and because the alternative later means +the same work *plus* reworking a client built around "wait your turn". There is no data migration +either way: we persist intents, not state. + +**What it buys later.** There is exactly one `NOT_YOUR_TURN` gate in the engine (`apply.ts:463`, +delegating to `isActor`). Once turn state is per-player, allowing genuinely local work to happen +off-cursor is a change to that one function: + +``` +isActor(s, player, intent) + local intent -> that player's own turn isn't done + shared intent -> player is at the shared cursor +``` + +An intent is **local** iff it touches only the acting player's Office Area **and** does not advance +the shared RNG (three sites: the reshuffle, the D12 that schedules a train, and setup). + +| Local | Shared | +| --- | --- | +| `switch.move`, `dropCars`, `sortConsist` | `draw.fromHomeOffice`, `draw.fromDepartment` | +| `card.play` — track, facility, office, modifier, enhancement | `card.discard` (buries a shared pile) | +| `localOps.choose`, `switch.end`, `draw.end` | `card.play` — **train cards**, which roll the D12 | +| | `freightAgent.*`, and the yards generally | + +That flip is **not** part of this plan. See D19. + +--- + +## 7. Redaction — the trust boundary + +`protocol.md` §4's table governs, and `Frame` was designed for it: `Frame.hand` is the *viewer's own*, +`Frame.deck` is a count, and the seed is not on it. + +**The redaction surface is four fields, not sixty event types.** An earlier draft of this document +claimed the latter and it was wrong — it biased the design. The genuinely secret things are: + +| Secret | Why | +| --- | --- | +| `seed`, `rngState` | leak every future shuffle and roll | +| `decks.homeOffice` — contents *and* order | §12.1; the count is public, the pile's height is visible | +| `decks.hands` — other players' | owner only; counts public | +| `cards` (the id→kind map) | the dictionary that turns any leaked id into a known card | + +Everything else is public by the rules: the board, the Division, trays and consists, the timetable, +the yards, the salvage pile, the face-up Departments, revenues, held Red Flags, the clock and whose +turn it is. `trainScheduled` is **public** — `trainNumber`, `roll` and `slot` all belong on screen; +only its `rngState` field must be stripped. + +So redaction reduces to: **call `snapshot(s, seat)` and never send `GameState`.** Because `Frame` +resolves card names and descriptions server-side, the client never needs the `cards` dictionary at +all. + +**This must be enforced, not assumed.** Phase 2's redaction test is the single most important test in +the plan: serialize a seat's payload and assert it contains no other seat's card ids, no deck array, +and no rng state. + +--- + +## 8. Server shape + +One process, one port, same origin. Three layers: + +``` + transport HTTP: lobby, intents, static assets. SSE: the per-seat stream. + | + game session one per active game: owns state, serializes intents, pumps advance, + | computes per-seat Frame+Menu, appends to the log + | + rules engine unchanged, imported as-is from src/engine/ +``` + +The session host is thin, because the engine does the hard part: + +``` +on intent(seat, intent, seq): + if seq already applied: ignore // idempotent resend + result = applyIntent(state, seat, intent) + if not result.ok: reply Rejected(result.code) + append intent to the log + pump(state) // drain the automatic phases + for each connected seat: + push { frameΔ: delta(snapshot(state, seat)), menu: seat may act ? menuFor(seat) : null, lines } +``` + +`pump` after every intent is what today's `drain()` does, and it is why the Mainline Phase needs no +special handling: `pump` stops, and the next push simply carries a `Menu` containing +`mainline.clearance` for whoever must rule. + +**Bots run server-side** using the existing `developerBot`, for seats chosen at lobby time only. + +--- + +## 9. Transport, and multiple addresses + +**SSE for push, HTTP POST for intents.** + +``` +POST /api/lobby/create, /api/lobby/join lobby +POST /api/intent { gameId, seq, intent } +GET /api/stream EventSource — per-seat frames, with Last-Event-ID resume +GET / the client +``` + +Chosen over WebSocket because this game is **idle most of the time** — turn-based with human +think-time means a connection sits silent for minutes, exactly when proxies reap sockets. SSE's +reconnection and `Last-Event-ID` resume are handled by the browser, and it needs no `Upgrade` support +from anything in the path. WebSocket is supported on StartOS (`recipe-multi-interface.md`; +`cln-startos` ships one) and remains a contained swap behind the `Session` interface if +bidirectionality is ever wanted. Two channels means intents carry a `seq`, which the design already +required for idempotent resend. + +**Players will reach one server at different addresses** — a LAN `IP:port` and a clearnet subdomain, +in the same game. That works because the server serves the client from the same origin, so each +browser is same-origin with itself and **no CORS is involved at any point**. One rule makes it hold: + +> The client derives every endpoint from `location`. Never from configuration, never baked in. + +Two consequences: + +- **`localStorage` is per-origin**, so a session token exists only at the address it was created at. + **A player rejoins at the address they joined from.** This is a stated constraint, not a bug to fix. +- The two paths have different reliability characteristics — only the clearnet player traverses TLS + termination and ingress. SSE's automatic recovery is what makes that difference not matter. + +--- + +## 10. Identity, access and persistence + +**Access: a server-wide join secret**, set by environment variable and passed out of band by whoever +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: + +``` +Session token, gameId, seat, displayName +``` + +The join secret is separate and server-wide — typed once and kept per-origin for convenience. It +gates entry; the token identifies a seat. + +**One game at a time per person is the expected usage, and is deliberately not enforced.** Enforcing +it would mean a server-scoped token carrying an `activeGame`, and then answering what clears it — a +finished game, a player who leaves, a game that never ends — which is real state to get wrong for no +benefit. Nothing in the design needs a person to be in only one game, so nothing checks. A browser +that ends up holding two tokens simply has two games. + +**Persistence: `{ engineVersion, seed, config, history: Intent[] }`**, append-only per game, plus a +small index. Rebuilding is `fromSave`, already implemented and exercised by every published replay. +No state snapshot — see D6. + +**Games do not survive a rules change, by design.** A saved intent legal under old rules is rejected +under new ones; that happened four times in one release. So every game is stamped with its engine +version, and on load a mismatch is **refused with an explicit message** rather than silently +truncated. The upgrade path is: stop new games on the old version, let running ones drain. + +**Retention: finished games keep everything**, because replay reveals everything (D20) and the log is +all it needs. + +--- + +## 11. Decisions — reviewed and settled + +| # | Decision | Rationale | +| --- | --- | --- | +| **D1** | **SSE + HTTP POST** | The game is idle for minutes at a time and players arrive by different paths; browser-native reconnect and resume, no `Upgrade` dependency. Swap is contained behind `Session`. | +| **D2/D3** | **Push `Frame` + `Menu`, delta'd. No raw event stream.** | Latency is not the deciding factor (1.7 s per game on a LAN), so decide on correctness: one reducer, one redaction chokepoint, no card dictionary on the client. 2.9 KB per push with the board omitted when unchanged — reusing `replay.ts`'s packing, already tested for losslessness. | +| **D4** | **One client bundle**, mode switch | The engine ships either way; in remote mode it is simply not used for authority. | +| **D5** | **Persist intents**, not events | Smaller, already the save format, already proven by `fromSave`. | +| **D6** | **No state snapshot** | A second format to keep correct, and D7 removes the need. | +| **D7** | **Stamp the engine version; refuse to resume a mismatch** | Rules changes invalidate stored intents. Drain before upgrading. | +| **D8** | **Bots fill empty seats at lobby time only** | Makes short-handed games and one-person testing possible. Never automatic on disconnect — see the two `TODO.md` items. | +| **D9** | **Separate seat from identity, now** | Employee Rotation needs a player's score to follow them while their seat changes. The single hardest thing here to retrofit, and the codebase is smallest today. | +| **D10** | **No hotseat** in v1 | Nearly free once seats exist, but a different UX. Additive later. | +| **D11** | **Server accepts 1-seat games**, but solitaire stays the static build | Falls out of seats generalising, and is genuinely useful for testing and validation. Not a featured path — playing alone gains nothing from a round trip. | +| **D12** | **No spectators** in v1 | A view with no private section and no intent rights. Additive later. | +| **D13** | **No accounts** | Display name plus a per-game session token, as `lobby-and-sessions.md` describes. One game at a time per person is the expected usage but is **not enforced** — doing so would add cross-game state to get wrong for no benefit. | +| **D14** | **Server-wide join secret**, passed out of band | The server may be clearnet-reachable. Cheapest thing that stops it being someone else's game server. Revisit. | +| **D15** | **Build to `deployment.md`'s five portability rules; package as an `.s9pk`** | This is a StartOS packaging workspace and the toolchain is on disk. | +| **D16** | **The server serves the client** | Required for same-origin, which is what makes multiple access addresses work without CORS. | +| **D17** | **Undo is solitaire-only** | Other players have seen the result. | +| **D18** | **Player cap 6** | Per `lobby-and-sessions.md` §2. | +| **D19** | **Per-player turn state now; parallel turns deferred** | The model change is behaviour-neutral and cheap today. Whether local work should run off-cursor depends on how often humans choose to switch — 13% for the bot, and the benefit ranges from ~8 minutes to ~27 off an hour-long game between 13% and 50%. Measure with real players, then flip one function. | +| **D20** | **Replay reveals everything once the game ends** | Most useful for learning and for arguing about it afterwards; costs nothing extra to retain. | + +**Still open, and deliberately so:** whether local turns should run in parallel (D19, needs human +switching data); whether the join secret is enough (D14); and the ~16-Stage game length in §3, which +is a balance question rather than an architecture one. + +--- + +## 12. Build plan + +Sizes use `components.md`'s scale. Each phase ends somewhere demonstrable. + +### Phase 0 — Engine and client preparation · M — **done, v0.4.0** + +Safe to do now, and all of it improves the code whether or not multiplayer ships. Larger than a +typical "phase 0" because two structural changes are cheapest here. + +1. **Separate seat from identity** (D9). `Player` carries id, name and revenue; the seat carries the + Office. Touches setup, `areaOf`, scoring, seating, the Division build and most tests. +2. **Per-player turn state** (D19). `turns: Map`, one per player at phase + entry, `turnOf(s, player)` at 50 sites. **Behaviour-neutral** — the cursor still walks one player + at a time, and every existing test must still pass unchanged. +3. Add `option`, `status`, `outcome`, `players[]`, `handCount` to `Frame`. +4. Remove all 11 `game.state` reads from `main.ts`; add a test that it never regains one. +5. Rewrite `protocol.md` to reference `intents.ts` and `events.ts` rather than restate them. + +**Done when:** solitaire plays identically, the client renders from `Frame` + `Menu` alone, and the +engine has per-player turn state that nothing yet exploits. *All five shipped in v0.4.0. Item 1 found +a real bug on the way — `awardDeparture` was indexing `s.players` with a seat.* + +### Phase 1 — The Session boundary · S — **done, v0.4.0** + +6. Define `Session`; implement `LocalSession` around today's `Game`. +7. Move `main.ts` onto it, with `capabilities` gating undo, save and new-game. + +**Done when:** solitaire plays identically through the new interface. Maximum "nothing appears to have +happened"; the regression suite is the proof. *Shipped in v0.4.0. The proof is `test/session.test.ts`, +which plays seed 77 to a finish through both routes and asserts the boards and histories match, plus a +source check that `main.ts` never regains a `GameState` read or a value import from `game.ts`.* + +### Phase 2 — Server core, one game, no lobby · M + +8. Process bootstrap: env-configured bind address, port, data directory, join secret; static serving. +9. Game session host — the loop in §8. +10. Per-seat `Frame` + `Menu`, with the board delta reusing `replay.ts`'s packing. +11. **The redaction test** (§7). Fail loudly. +12. SSE stream and `POST /api/intent`, with `seq` and `Last-Event-ID`. +13. `RemoteSession` in the client. + +**Done when:** two browsers — one on a LAN IP, one on a hostname — play a 2-player game to a finish. + +### Phase 3 — Persistence and resumption · S + +14. Append-only `{engineVersion, seed, config, history}` per game; index. +15. Load on start; rebuild via `fromSave`; refuse a version mismatch explicitly. + +**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. + +**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. + +**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. + +**Done when:** it installs on a StartOS box and players on two different addresses play a game. + +**Shape of it:** Phases 0–1 are low-risk refactoring that stands on its own merits. Phases 2–4 are the +real system. Phase 5 is content and independent of the rest. Phase 6 is packaging. + +--- + +## 13. Risks + +**R1 — The switching UI is still the sleeper.** `components.md` called it that and it remains the +hardest interface in the game. Multiplayer does not make it harder, but four people now watch one +person use it. + +**R2 — Redaction must be exactly right.** Everything else degrades gracefully; a redaction bug hands +someone else's hand to a player and cannot be walked back. Hence the explicit test, not a review. + +**R3 — `Menu` peaks at 9.8 KB** against a 2.2 KB mean. If that bites, send placements only for a +selected card. Measure before optimising. + +**R4 — Idle time, not latency, is what makes 4-player games drag.** You watch ~17 of 22 intents per +Stage. D19 keeps the door open; the instrumentation to decide it is a few lines and belongs in the +first real multiplayer games. + +**R5 — The two provisional rules are still unplaytested.** The opening deal and departure Revenue are +both flagged for review in `TODO.md`. Phases 0–1 are safe regardless; **Phase 2 onward should wait +until those settle**, or the server gets built against rules that are still moving. diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 7318b10..07306d1 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -115,13 +115,16 @@ trains move and wrecks happen while the Superintendent rules on clearances. That means the server must push. A request/response API alone would leave four players polling to watch a fifth switch cars. -**WebSocket, with the HTTP endpoints alongside it** for lobby operations (list games, create, join) -where a request/response shape is the natural fit. Server-Sent Events would also serve, since the -push is nearly one-directional and intents are rare enough to send over HTTP — worth keeping in mind -if the eventual stack makes SSE materially simpler. +**Server-Sent Events, with HTTP POST alongside** for lobby operations and for intents, where a +request/response shape is the natural fit. This paragraph originally leaned the other way, toward +WebSocket with SSE as the fallback; it was settled the other way in +[`multiplayer.md`](multiplayer.md) D5, because the push is nearly one-directional, intents are rare, +and SSE reconnects itself through anything in the path without a protocol upgrade to negotiate. -What matters more than the choice: **every state change reaches clients as an event on one ordered -stream**. Clients apply events in order and never mutate state locally in a way that could drift. +What matters more than the choice: **every state change reaches clients on one ordered stream**, and +clients never mutate state locally in a way that could drift. What travels on that stream is a +redacted `Frame`, not the raw events — see `multiplayer.md` D2/D3 for why one reducer and one +redaction chokepoint beat shipping the history to every client. --- @@ -159,7 +162,7 @@ Keep the **rules engine** free of any knowledge of networking, storage, or playe │ game session owns one game's state, applies intents, emits events ▲ - lobby / transport connections, reconnection, persistence, HTTP + WebSocket + lobby / transport connections, reconnection, persistence, HTTP POST + SSE ``` The rules engine being pure and deterministic is what makes the whole thing testable — you can drive diff --git a/docs/architecture/protocol.md b/docs/architecture/protocol.md index 3495830..289f5c0 100644 --- a/docs/architecture/protocol.md +++ b/docs/architecture/protocol.md @@ -1,269 +1,138 @@ # Protocol -The client↔server message vocabulary, and how per-player views are redacted. Written against -[`../rules/rules-v0.2.md`](../rules/rules-v0.2.md) and [`game-state.md`](game-state.md). +The client↔server message vocabulary, and how per-player views are redacted. -Three message families, matching the flows in [`overview.md`](overview.md): +**The vocabulary is code, not prose.** This document used to spell out every message with an +illustrative name and an illustrative payload. It was written before the engine existed, and the +engine then went and defined all three families for real — so the document became a second, +drifting, subtly-wrong copy of three TypeScript files. It is now a map of those files plus the rules +that live nowhere else. -- **Intent** — client → server. A proposal. May be rejected. -- **Event** — server → clients. A fact. Ordered, and the game's history. -- **View** — server → one client. A redacted state snapshot. +| 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 | +| **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` | -Message names below are illustrative. What matters is the *set* of decisions a player can make, which -is fixed by the rules. +Read those files for the shapes. Each variant carries its own doc comment explaining the rule behind +it and, where the field arrangement is load-bearing, why it is arranged that way. What follows is +what the types cannot say. + +The transport that carries these — SSE down, HTTP POST up — and the decision to push `Frame` + `Menu` +rather than a raw event stream, are in [`multiplayer.md`](multiplayer.md) (D2/D3, D5). Written against +[`../rules/rules-v0.2.md`](../rules/rules-v0.2.md) and [`game-state.md`](game-state.md); the flows are +in [`overview.md`](overview.md). --- ## 1. Intents -Every intent carries `gameId`, `playerId`, and a `seq` the server uses to reject duplicates from a -reconnecting client. +**Intents are already the wire format.** A saved game is `{ seed, history: Intent[] }` and `fromSave` +replays it, which means the client→server message type was fixed the day save/restore worked. There +is no separate protocol layer to design and nothing to keep in sync. -### 1.1 Local Operations Phase +Every intent is validated server-side even when the client only ever offers legal ones — `legalActions` +and `check` are the same code, so a client-side menu is a convenience, never a guarantee. +The framing an intent needs on the wire (`gameId`, who sent it, a `seq` for de-duplicating a +reconnecting client's resend) is added by the transport and is not part of `Intent` itself: the engine +is given the actor by its caller. -The phase opens with a three-way exclusive choice (§6). Choosing one forecloses the others for that -Stage. +### What is deliberately NOT an intent -``` -LocalOps.Choose { option: switch | draw | freightAgent } -``` +These are the places where offering a choice would be a rules violation, and they are worth stating +here because "the message is missing" looks like an oversight in a way that "the message exists" never +does. -**If `switch`** — six Moves, divisible across Crew Trays (§6.1). Five Moves under Reduced Visibility -during night Stages (Appendix B). +- **Picking up cars.** Moving into standing cars couples them automatically and mandatorily (§A.4). + The engine does it while resolving `switch.move`. +- **Which cars come off.** `switch.dropCars` takes a *count*: cars come off in seated order (§A.3), so + the only free choice is how many, and from which end (`fromNose`). +- **Mainline movement.** The whole phase is automatic except the Superintendent's clearance decision + (`mainline.clearance`, §8.1) — which arrives out of turn order and may concern another player's + train. It is the one place a player acts during someone else's traffic. +- **Scheduling.** `trainScheduled` comes from the 1D12 (§7); nobody chooses it. -``` -Switch.Move { trayId, to: GridCoord } -Switch.DropCars { trayId, count } -- from the tray end; order is fixed (§A.3) -Switch.SetDirection{ trayId, direction } -- costs a Move; a Move never reverses (§2.4) -Switch.End { } -- forfeit remaining Moves -``` +### Two loops the client must not flatten -Pick-up is **not** an intent. Moving into standing cars couples them automatically and mandatorily -(§A.4) — the server does it as part of resolving `Switch.Move`. Offering a choice would be a rules -violation. - -`Switch.DropCars` takes a count, not a set of car ids: cars come off in seated order (§A.3), so the -only free choice is how many. - -**If `draw`** (§6.2): - -``` -Draw.FromHomeOffice { } -Draw.FromDepartment { slot: 0|1|2 } -Card.Play { cardId, placement? } -- placement required for track/facility/office cards -Card.Discard { cardId, toSlot: 0|1|2 } -Draw.End { } -- server rejects while hand > 3 -``` - -`placement` is a `GridCoord` for track, Facility and Office cards (§11.2); absent for train cards, -which go to the timetable or to an Extra's staging. - -**If `freightAgent`** — exactly one of three operations (§6.3): - -``` -FreightAgent.StockToOutbound { facilityId, stockType, loaded } -FreightAgent.InboundToClass { facilityId, stockIndex } -FreightAgent.UnjamToClass { facilityId, from: outbound|inbound|menAtWork, index } -``` - -### 1.2 New Train Phase - -Per train being made up, each player adds one car in Superintendent-then-left order (§7): - -``` -NewTrain.PlaceCar { trainId, stockType, loaded } -NewTrain.PassCar { trainId } -- only legal when no suitable car exists in the Division Yard -``` - -**This cycles** (§7, Gap 9). The server keeps going round the table — one car per player per pass — -until the consist is full or no suitable car remains in the Division Yard. It is not a single pass, so -the client must not assume one prompt per player per train. At five or more players the round ends -mid-pass when the consist fills; players not yet reached simply are not prompted. - -`PassCar` must be validated, not trusted: §7 requires the player to "make every effort to find a -suitable car." The server checks the Division Yard against the train's `consistSpec` and rejects a -pass when a legal car is available. A pass by every player in a full pass is the loop's other -termination condition. - -For a played Extra (§7, Gap 4c) the owning player chooses its whole consist: - -``` -Extra.Launch { trainCardId, at: westDP | eastDP, direction } -Extra.LoadConsist { trainCardId, stock: [{stockType, loaded}] } -- ≤3 cars + caboose -``` - -### 1.3 Mainline Phase - -The phase is automatic **except** for the Superintendent's clearance decision (§8.1, fourth -condition). The server pauses movement, sets `clock.pendingDecision`, and waits: - -``` -Mainline.Clearance { trainId, allow: boolean } -``` - -Only the current Superintendent may send this, and only for the train named in the pending decision. -This intent arrives out of the normal turn order and may concern another player's train — the one -place in the game where a player acts during someone else's traffic. - -The Red Flag card (Emergency Toolbox, Appendix B) also interrupts here: - -``` -RedFlag.Play { } -- averts an imminent collision; card is removed from the game -``` - -### 1.4 Load/Unload Phase - -Each Laborer and Porter is usable once per Stage (§9.1). Resolve one at a time. - -``` -Porter.Board { facilityId } -- §9.2, +1 Revenue -Porter.Detrain { facilityId } -- §9.2, +1 Revenue -Laborer.AdvanceLoad { facilityId, loadRef } -- Green → MEN → AT → WORK → car (§9.3) -Laborer.BeginUnload { facilityId, carIndex } -- first step of unloading -LoadUnload.End { } -``` - -A freight load needs four `Laborer.AdvanceLoad` intents to score one point; a Porter scores in one -(§9.3). The client should show this clearly — it is the single most surprising thing about the game's -economy for a new player. - -### 1.5 Lobby - -Covered in [`lobby-and-sessions.md`](lobby-and-sessions.md); listed here for completeness. - -``` -Lobby.Create { config } -Lobby.Join { gameCode, displayName } -Lobby.Leave { } -Lobby.SetConfig { config } -- host only, before start -Lobby.Start { } -- host only -``` +- **New Train cycles.** §7 goes round the table one car per player per pass until the consist is full + or no suitable car remains. It is *not* one prompt per player per train. At five or more players a + round can end mid-pass with players never prompted. +- **`newTrain.passCar` is validated, not trusted.** §7 requires "every effort to find a suitable car", + so the engine checks the Division Yard against the consist spec and refuses a pass when a legal car + is there. --- ## 2. Rejections -An intent is rejected with a reason a client can render, not a stack trace: +`RejectionCode` in `intents.ts` is the closed set. `applyIntent` returns +`{ ok: false, code, message }` — a reason a client can show, never a stack trace. The `message` today +is the code restated (`apply.ts`); a client that wants prose should key off `code`, which is stable, +rather than parsing it. -``` -Rejected { seq, code, message, ... } - -codes: NOT_YOUR_TURN | WRONG_PHASE | OPTION_ALREADY_CHOSEN | NO_MOVES_REMAINING - ILLEGAL_MOVE | TRACK_OCCUPIED | NOT_OPERATIONAL_RAIL | WOULD_REVERSE - CONSIST_FULL | CONSIST_ORDER | HAND_LIMIT | CARD_NOT_IN_HAND - NO_PLACEMENT | NOT_CONNECTED | RESOURCE_SPENT | SUITABLE_CAR_EXISTS - NOT_SUPERINTENDENT | NO_PENDING_DECISION -``` - -The client may pre-validate to grey out illegal moves — good UX — but the server's answer is the only -one that counts (`overview.md`). Rejections should be rare in a well-built client and are therefore -worth logging server-side: a spike usually means client and server rules have drifted. +A client may pre-validate to grey out illegal moves — that is what `legalActions` is for, and it is +good UX — but the server's answer is the only one that counts. **Rejections should therefore be rare, +which makes them worth logging server-side: a spike means client and server rules have drifted.** --- ## 3. Events -Events are the authoritative history. State is `fold(events)`, which is what gives reconnection, -persistence and replay for one design decision. +Events are the authoritative history: `state = fold(events)`, which buys reconnection, persistence and +post-game replay for one design decision. -**Phase and clock** +**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, +or a replay viewer has to reconstruct the whole board to draw one frame. -``` -StageBegan { day, stage } -PhaseBegan { phase } -ActorChanged { playerIndex | null } -SuperintendentChanged { playerIndex } -DayEnded { day, revenues } -``` +Two consequences worth knowing before adding an event: -**Local operations** - -``` -LocalOpsOptionChosen { playerIndex, option } -TrayMoved { trayId, from, to, movesRemaining } -CarsCoupled { trayId, stock[] } -- automatic, from a Move -CarsDropped { trayId, at, stock[] } -CardDrawn { playerIndex, source, cardId? } -- cardId omitted for other players -CardPlayed { playerIndex, cardId, placement? } -OfficeUpgraded { playerIndex, from, to } -- property change; connections unaffected (§11.3) -CardDiscarded { playerIndex, cardId, toSlot } -DeckReshuffled { } -``` - -**Trains** - -``` -TrainScheduled { trainCardId, timetableSlot } -- from the 1D12 roll (§7) -TrainMadeUp { trainCardId, trayId, at } -CarPlacedOnTrain { playerIndex, trayId, stock } -ClearanceRequested { trainId, followingInto, occupiedBy } -ClearanceGiven { trainId, allow } -TrainHighballed { trayId, from, to } -TrainMoved { trayId, from, to } -TrainCompleted { trayId, atDivisionPoint } -``` - -**Consequences** - -``` -CollisionOccurred { at, trains[], struckCars[], faultPlayer, penalty: 5 } -RedFlagPlayed { playerIndex, avertedAt } -RevenueChanged { playerIndex, delta, total, reason } -CollisionCountChanged { collisionsToday } -GameEnded { result, winner?, reason } -``` - -`CollisionOccurred` carries `faultPlayer` explicitly rather than leaving clients to derive it. Fault -depends on *where* the wreck happened — Superintendent for a Mainline card, the local player between -his Limits (§10) — and getting that wrong misattributes a −5 and, in Competitive, contributes to a -floor that ends the game. +- **`collisionOccurred` carries `faultPlayer` explicitly** rather than leaving clients to derive it. + 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. --- ## 4. Views and redaction -Each client receives a projection with other players' private state removed. +Each client receives `snapshot(state, …, seat)` — a `Frame`, which is already a projection built for +rendering and already takes the viewing seat. Nothing else is sent. | State | Visibility | | --- | --- | | Board: track grids, Offices, Limits, trains, standing cars | **Public** | | Facilities: boxes, `MEN AT WORK`, Laborer/Porter usage | **Public** | | Division Yard, Classification Yard contents | **Public** — physical piles on the table | -| Salvage Yard | **Public** — face up, explicitly so players can audit discards (§2.6) | +| Salvage Yard | **Public** — face up, so players can audit discards (§2.6) | | Department slots (the three face-up cards) | **Public** | | Home Office deck **contents and order** | **Secret** — never sent, to anyone | | Home Office deck **count** | Public — players can see the pile's height | | A player's hand | **Owner only**; others see the count | -| Red Flag held | Public — it is a known starting card (Appendix B) | +| Red Flag held | Public — a known starting card (Appendix B) | | Revenue totals | **Public** — the race is the game | -| RNG seed | **Secret** — sending it leaks all future shuffles | +| RNG seed and state | **Secret** — either leaks every future shuffle and roll | -``` -View { - public : PublicState -- identical for every client - private : { hand: CardId[], redFlag: boolean } - you : PlayerIndex - canAct : boolean - legalIntents? : IntentSummary[] -- optional server-computed affordances -} -``` +**The redaction surface is four fields, not sixty event types** — `seed`, `rngState`, +`deckReshuffled.order`, and `cardDrawn.cardId` when the draw was from the Home Office. The full +argument, including why `trainScheduled` is public despite being a die roll, is in +[`multiplayer.md` §7](multiplayer.md). It reduces to: **call `snapshot` and never send `GameState`.** -**Two redaction traps.** First, `CardDrawn` from the Home Office must omit `cardId` for every client -except the drawer — the obvious version of this event leaks the draw to the table. Second, the deck -*count* is public but the *order* is secret; a naive implementation that ships the deck array and -tells the client not to look is not redaction. - -`legalIntents` is optional but worth it: the server already computes legality to validate, so -returning the affordance set costs little and removes any need for the client to reimplement rules -like turnout directionality or the four-slot consist limit. +**This is enforced, not assumed.** The redaction test is the single most important test in the +multiplayer work: everything else degrades gracefully, a redaction bug hands a player the deck. --- ## 5. Ordering and idempotency -- Events carry a monotonic `eventSeq` per game. Clients apply strictly in order and request a replay - on a gap rather than guessing. +- Events carry a monotonic sequence per game. Clients apply strictly in order and request a replay on + a gap rather than guessing. - Intents carry a client `seq`. The server ignores a repeat of one it has already applied, so a reconnecting client can safely resend anything it is unsure about. -- The server never applies two intents concurrently within a game. With one actor at a time this is - free — a per-game queue is sufficient and there is no need for anything cleverer at this scale. +- **The server never applies two intents concurrently within a game.** A per-game queue is sufficient + and there is nothing cleverer to do at this scale. Note this is a serialisation rule, not a + one-actor-at-a-time rule: per-player turn state (`turns: Map`) means several + players may hold an open turn at once, and their intents still land one at a time. diff --git a/docs/design.md b/docs/design.md index 8edf257..9faf7ad 100644 --- a/docs/design.md +++ b/docs/design.md @@ -45,6 +45,7 @@ must do. | [`architecture/protocol.md`](architecture/protocol.md) | Intents, events, and per-player view redaction. | | [`architecture/lobby-and-sessions.md`](architecture/lobby-and-sessions.md) | Create/join, seating, reconnection, persistence. | | [`architecture/deployment.md`](architecture/deployment.md) | Plain web host vs StartOS service — and the one constraint that differs. | +| [`architecture/multiplayer.md`](architecture/multiplayer.md) | **The multiplayer build plan.** The authoritative server as a layer added on top, with solitaire unchanged and server-free — plus every decision taken, listed for review. | ## Current status diff --git a/package.json b/package.json index 5045f3d..d55a87f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "station-master", - "version": "0.3.1", + "version": "0.4.0", "private": true, "type": "module", "description": "Station Master — a railroad operations game", diff --git a/src/engine/advance.ts b/src/engine/advance.ts index ad4681c..3f3225e 100644 --- a/src/engine/advance.ts +++ b/src/engine/advance.ts @@ -36,10 +36,10 @@ import type { Direction } from './content.ts'; import type { GameEvent } from './events.ts'; // `trainNeedingCars` lives in apply.ts beside `check`'s copy of the same question, so the phase and // the legality test cannot disagree about which train is being assembled. -import { areaOf, trainNeedingCars } from './apply.ts'; +import { areaAtSeat, areaOf, trainNeedingCars } from './apply.ts'; import { legalActions } from './legal.ts'; -import type { CrewTray, DivisionNode, GameState, PlayerIndex, RollingStock, TrayId } from './state.ts'; -import { coordKey, freshTurn, subdivisions, totalRevenue } from './state.ts'; +import type { CrewTray, DivisionNode, GameState, PlayerIndex, RollingStock, SeatIndex, TrayId } from './state.ts'; +import { coordKey, freshTurns, playerAtSeat, subdivisions, totalRevenue, turnOf } from './state.ts'; export type AdvanceResult = { events: GameEvent[]; @@ -61,8 +61,8 @@ function movesForStage(s: GameState): number { // Division navigation // --------------------------------------------------------------------------- -function nodeIndexOfOffice(s: GameState, owner: PlayerIndex): number { - return s.division.nodes.findIndex((n) => n.kind === 'office' && n.owner === owner); +function nodeIndexOfOffice(s: GameState, seat: SeatIndex): number { + return s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === seat); } const step = (d: Direction): number => (d === 'east' ? 1 : -1); @@ -101,31 +101,41 @@ function playerPhase( events: GameEvent[], phase: 'localOps' | 'loadUnload', ): AdvanceResult { + /** + * THE CURSOR STILL WALKS ONE PLAYER AT A TIME. + * + * Turn state is now per-player, but only the player at `actorOffset` is ever asked to act, so this + * behaves exactly as it did when there was a single `TurnState` — which is the point: the model + * change is neutral, and letting local work happen off-cursor is a later change to `isActor` + * alone (`docs/architecture/multiplayer.md` D19). + */ + const actor = actorAt(s, s.clock.actorOffset); + const turn = turnOf(s, actor); + // A Freight Agent operation is a whole action in itself, so the turn ends with it (§6.3). - if (phase === 'localOps' && s.turn.option === 'freightAgent' && s.turn.freightAgentUsed) { - s.turn.done = true; + if (phase === 'localOps' && turn.option === 'freightAgent' && turn.freightAgentUsed) { + turn.done = true; } // Spending the last Move ends a switching turn without needing an explicit end (§6.1). - if (phase === 'localOps' && s.turn.option === 'switch' && s.turn.movesRemaining === 0) { - s.turn.done = true; + if (phase === 'localOps' && turn.option === 'switch' && turn.movesRemaining === 0) { + turn.done = true; } - if (!s.turn.done) { - s.clock.currentActor = actorAt(s, s.clock.actorOffset); + if (!turn.done) { + s.clock.currentActor = actor; // SAFETY NET. If the actor has no legal action at all, the turn ends rather than deadlocking. // This should never fire — an option with no follow-up is already unavailable (§6, apply.ts) — // but a rules gap that stranded a player would otherwise hang the game rather than fail // visibly, and a hung game is far harder to diagnose than a forfeited turn. if (legalActions(s, s.clock.currentActor).length === 0) { - s.turn.done = true; + turn.done = true; } else { return { events, needsInput: true }; } } s.clock.actorOffset += 1; - s.turn = freshTurn(movesForStage(s)); if (s.clock.actorOffset >= s.players.length) { return { events: [...events, ...enterPhase(s, nextPhase(phase))], needsInput: false }; @@ -158,7 +168,9 @@ function nextPhase(p: GameState['clock']['phase']): GameState['clock']['phase'] function enterPhase(s: GameState, phase: GameState['clock']['phase']): GameEvent[] { s.clock.phase = phase; s.clock.actorOffset = 0; - s.turn = freshTurn(movesForStage(s)); + // Every player gets a turn at phase entry, not one at a time as the cursor reaches them. With the + // cursor still walking sequentially this is indistinguishable from the old behaviour. + s.turns = freshTurns(s.players.length, movesForStage(s)); s.clock.currentActor = phase === 'mainline' ? null : actorAt(s, 0); return [ { type: 'phaseBegan', phase }, @@ -329,7 +341,8 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult { (tray.position.coord.row === where.coord.row && tray.position.coord.col === where.coord.col)); if (moved !== 'expedited' && stillThere) { tray.stopPointClaimed = true; - const owner = tray.position.at === 'grid' ? tray.position.owner : 0; + // The point goes to whoever is SITTING in the district it stopped in. + const owner = tray.position.at === 'grid' ? playerAtSeat(s, tray.position.seat) : 0; const label = tray.position.at === 'grid' ? `(${tray.position.coord.row},${tray.position.coord.col})` @@ -431,8 +444,9 @@ function spendDispatchBonus( const mine = tray.trainNumber ?? 99; const theirs = other.trainNumber ?? 99; - const area = s.officeAreas.get(s.clock.superintendent); - if (!area) return 0; + // The Fedora is held by a PLAYER, and the dispatch devices are installed in an Office Area, which + // is keyed by SEAT. Indexing one with the other is right only while seating is the identity map. + const area = areaOf(s, s.clock.superintendent); // Best device first — Radio (+12) beats Telephone (+8) beats Telegraph (+4). for (const key of ['radio', 'telephone', 'telegraph'] as const) { @@ -503,8 +517,8 @@ function moveTrain( // Without this a train that arrives at an Office never leaves, holding an A/D track forever and // colliding with every train that follows it. if (tray.position.at === 'grid') { - const owner = tray.position.owner; - const area = areaOf(s, owner); + const seat = tray.position.seat; + const area = areaAtSeat(s, seat); // Only a train at the Office itself is eligible; one on Secondary Track is not (§8.1, Gap 2b). if ( tray.position.coord.row !== area.officeCoord.row || @@ -533,7 +547,7 @@ function moveTrain( return 'held'; } - const officeIndex = nodeIndexOfOffice(s, owner); + const officeIndex = nodeIndexOfOffice(s, seat); const target = officeIndex + dir; const node = s.division.nodes[target]; if (!node) return 'held'; @@ -551,7 +565,7 @@ function moveTrain( from: 'the Office', to: 'the Mainline', }); - awardDeparture(s, owner, tray, events); + awardDeparture(s, seat, tray, events); return 'moved'; } @@ -566,7 +580,7 @@ function moveTrain( */ if (node.kind === 'divisionPoint') { area.adOccupancy = area.adOccupancy.filter((t) => t !== id); - awardDeparture(s, owner, tray, events); + awardDeparture(s, seat, tray, events); retireTrain(s, id, tray, events); return 'moved'; } @@ -654,7 +668,7 @@ function moveTrain( } if (dest.kind === 'office') { - return arriveAtOffice(s, id, tray, dest.owner, events); + return arriveAtOffice(s, id, tray, dest.seat, events); } } @@ -758,10 +772,10 @@ function arriveAtOffice( s: GameState, id: TrayId, tray: CrewTray, - owner: PlayerIndex, + seat: SeatIndex, events: GameEvent[], ): MoveOutcome { - const area = areaOf(s, owner); + const area = areaAtSeat(s, seat); const capacity = officeProfile(area.tier).adTracks; const hasEnhancement = (key: string): boolean => [...area.grid.values()].some((c) => c.enhancements.includes(key)); @@ -773,7 +787,7 @@ function arriveAtOffice( for (const [key, card] of area.grid) { if (!card.enhancements.includes('yardOffice')) continue; const [row, col] = key.split(',').map(Number); - tray.position = { at: 'grid', owner, coord: { row: row!, col: col! } }; + tray.position = { at: 'grid', seat, coord: { row: row!, col: col! } }; events.push({ type: 'trainDiverted', trainNumber: tray.trainNumber ?? 0, @@ -799,7 +813,7 @@ function arriveAtOffice( }); return 'moved'; } - collide(s, owner, [id], events, 'no free A/D track', 'the Office'); + collide(s, playerAtSeat(s, seat), [id], events, 'no free A/D track', 'the Office'); return 'moved'; } @@ -808,7 +822,7 @@ function arriveAtOffice( const first = area.heldAtLimits.shift()!; area.adOccupancy.push(first); const held = s.trays.get(first); - if (held) held.position = { at: 'grid', owner, coord: area.officeCoord }; + if (held) held.position = { at: 'grid', seat, coord: area.officeCoord }; } area.heldAtLimits = area.heldAtLimits.filter((t) => t !== id); @@ -816,12 +830,12 @@ function arriveAtOffice( // is not expecting them (§A.4), so this is a collision too, not a coupling. const officeCard = area.grid.get(coordKey(area.officeCoord)); if (officeCard && officeCard.standing.length > 0) { - collide(s, owner, [id], events, 'cars fouling the Running Track', 'the Running Track'); + collide(s, playerAtSeat(s, seat), [id], events, 'cars fouling the Running Track', 'the Running Track'); return 'moved'; } area.adOccupancy.push(id); - tray.position = { at: 'grid', owner, coord: area.officeCoord }; + tray.position = { at: 'grid', seat, coord: area.officeCoord }; events.push({ type: 'trainArrived', trainNumber: tray.trainNumber ?? 0, @@ -913,11 +927,14 @@ function collide( */ function awardDeparture( s: GameState, - owner: PlayerIndex, + seat: SeatIndex, tray: CrewTray, events: GameEvent[], ): void { if (tray.trainNumber === null) return; + // Paid to whoever OCCUPIES the Office the train left, not to the seat's index. Identical today, + // and the difference is the whole point of the seat/player split. + const owner = playerAtSeat(s, seat); const p = s.players[owner]; if (!p) return; p.revenue += 1; diff --git a/src/engine/apply.ts b/src/engine/apply.ts index 5da43fe..27477c6 100644 --- a/src/engine/apply.ts +++ b/src/engine/apply.ts @@ -43,13 +43,14 @@ import type { OfficeArea, PlayerIndex, RollingStock, + SeatIndex, TrackArc, TrackCard, TrayId, TurnoutOrientation, } from './state.ts'; import { createRng } from './rng.ts'; -import { carsOn, coordKey, isOperationalRail, spaceOn } from './state.ts'; +import { carsOn, coordKey, isOperationalRail, playerAtSeat, seatOf, spaceOn, turnOf } from './state.ts'; import type { MoveBlock, Occupancy, Port } from './track.ts'; import { canDropCarsAt, @@ -70,9 +71,21 @@ export type ApplyResult = // Lookup helpers // --------------------------------------------------------------------------- +/** + * The Office Area belonging to a PLAYER — that is, the one at the seat they currently occupy. + * + * Goes through `seatOf` rather than indexing directly, which is the whole point of the seat/player + * split: today seating is the identity mapping so this is exactly what it always was, and under + * Employee Rotation it follows the player to their new chair without a single caller changing. + */ export function areaOf(s: GameState, player: PlayerIndex): OfficeArea { - const a = s.officeAreas.get(player); - if (!a) throw new Error(`no Office Area for player ${player}`); + return areaAtSeat(s, seatOf(s, player)); +} + +/** The Office Area at a POSITION on the Division, regardless of who is sitting there. */ +export function areaAtSeat(s: GameState, seat: SeatIndex): OfficeArea { + const a = s.officeAreas.get(seat); + if (!a) throw new Error(`no Office Area at seat ${seat}`); return a; } @@ -176,7 +189,7 @@ export function facilityCarType(f: Facility): CarType | null { export function hasSwitchOption(s: GameState, player: PlayerIndex): boolean { for (const tray of s.trays.values()) { - if (tray.position.at === 'grid' && tray.position.owner === player) return true; + if (tray.position.at === 'grid' && tray.position.seat === seatOf(s, player)) return true; } return false; } @@ -353,6 +366,11 @@ function trainAtOfficeWith( * and may do anything the general rules allow. Every restriction below is keyed off the CARD, so a * crew is unaffected by all of them. */ +/** Which district a tray is standing in; 0 when it is out on the Division. */ +function trayySeat(tray: CrewTray): SeatIndex { + return tray.position.at === 'grid' ? tray.position.seat : 0; +} + function rulesOf(tray: CrewTray): TrainRules { if (tray.trainNumber === null) return {}; return trainProfile(tray.trainNumber, tray.trainIsExtra)?.rules ?? {}; @@ -374,12 +392,13 @@ const isFreight = (c: RollingStock): boolean => c.type !== 'coach' && c.type !== */ function freightBudgetLeft( s: GameState, + player: PlayerIndex, tray: CrewTray, at: GridCoord, wanted: number, ): boolean { if (!rulesOf(tray).oneFreightPerLocation) return true; - const already = s.turn.freightWorked[freightWorkedKey(tray.id, at)] ?? 0; + const already = turnOf(s, player).freightWorked[freightWorkedKey(tray.id, at)] ?? 0; return already + wanted <= 1; } @@ -403,6 +422,7 @@ function switchingRefusal(tray: CrewTray): RejectionCode | null { */ function spendFreightBudget( s: GameState, + player: PlayerIndex, tray: CrewTray, at: GridCoord, stock: readonly RollingStock[], @@ -410,8 +430,9 @@ function spendFreightBudget( if (!rulesOf(tray).oneFreightPerLocation) return; const n = stock.filter(isFreight).length; if (n === 0) return; + const turn = turnOf(s, player); const key = freightWorkedKey(tray.id, at); - s.turn.freightWorked[key] = (s.turn.freightWorked[key] ?? 0) + n; + turn.freightWorked[key] = (turn.freightWorked[key] ?? 0) + n; } /** Trains that may not be worked by Porters at all (§7): the Military train and the Director's car. */ @@ -426,8 +447,7 @@ function refusesPassengers(tray: CrewTray): boolean { */ function refusesThisOffice(s: GameState, player: PlayerIndex, tray: CrewTray): boolean { if (!rulesOf(tray).terminalsOnly) return false; - const area = s.officeAreas.get(player); - return !area || area.tier !== 'terminal'; + return areaOf(s, player).tier !== 'terminal'; } /** @@ -466,7 +486,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo // -- Local Operations ----------------------------------------------------- case 'localOps.choose': if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; - if (s.turn.option !== null) return 'OPTION_ALREADY_CHOSEN'; + if (turnOf(s, player).option !== null) return 'OPTION_ALREADY_CHOSEN'; // An option with no possible follow-up is not available at all (§6). if (i.option === 'freightAgent' && !hasFreightAgentOption(s, player)) return 'NO_SUCH_FACILITY'; if (i.option === 'switch' && !hasSwitchOption(s, player)) return 'NO_SUCH_TRAY'; @@ -474,8 +494,8 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo case 'switch.move': { if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; - if (s.turn.option !== 'switch') return 'OPTION_NOT_CHOSEN'; - if (s.turn.movesRemaining < 1) return 'NO_MOVES_REMAINING'; + if (turnOf(s, player).option !== 'switch') return 'OPTION_NOT_CHOSEN'; + if (turnOf(s, player).movesRemaining < 1) return 'NO_MOVES_REMAINING'; const tray = s.trays.get(i.trayId); if (!tray) return 'NO_SUCH_TRAY'; const noSwitch = switchingRefusal(tray); @@ -496,14 +516,14 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo if (rules.dropOnly) return 'PICKUP_NOT_ALLOWED'; if (rules.pickUpEmptiesOnly && dest.couples.some((c) => c.loaded)) return 'EMPTIES_ONLY'; const freight = dest.couples.filter(isFreight).length; - if (freight > 0 && !freightBudgetLeft(s, tray, i.to, freight)) return 'FREIGHT_WORKED_HERE'; + if (freight > 0 && !freightBudgetLeft(s, player, tray, i.to, freight)) return 'FREIGHT_WORKED_HERE'; } return null; } case 'switch.dropCars': { if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; - if (s.turn.option !== 'switch') return 'OPTION_NOT_CHOSEN'; + if (turnOf(s, player).option !== 'switch') return 'OPTION_NOT_CHOSEN'; const tray = s.trays.get(i.trayId); if (!tray) return 'NO_SUCH_TRAY'; const noSwitch = switchingRefusal(tray); @@ -544,7 +564,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo return 'COACH_MUST_STAY'; } const droppedFreight = cut.filter(isFreight).length; - if (droppedFreight > 0 && !freightBudgetLeft(s, tray, here, droppedFreight)) { + if (droppedFreight > 0 && !freightBudgetLeft(s, player, tray, here, droppedFreight)) { return 'FREIGHT_WORKED_HERE'; } return canDropCarsAt(areaOf(s, player), here, i.count) ? null : 'CANNOT_DROP_HERE'; @@ -552,8 +572,8 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo case 'switch.sortConsist': { if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; - if (s.turn.option !== 'switch') return 'OPTION_NOT_CHOSEN'; - if (s.turn.movesRemaining < 1) return 'NO_MOVES_REMAINING'; + if (turnOf(s, player).option !== 'switch') return 'OPTION_NOT_CHOSEN'; + if (turnOf(s, player).movesRemaining < 1) return 'NO_MOVES_REMAINING'; const tray = s.trays.get(i.trayId); if (!tray) return 'NO_SUCH_TRAY'; const noSwitch = switchingRefusal(tray); @@ -573,26 +593,26 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo case 'switch.end': if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; - return s.turn.option === 'switch' ? null : 'OPTION_NOT_CHOSEN'; + return turnOf(s, player).option === 'switch' ? null : 'OPTION_NOT_CHOSEN'; // -- Draw a card ---------------------------------------------------------- case 'draw.fromHomeOffice': if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; - if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN'; - if (s.turn.drawnThisTurn) return 'OPTION_ALREADY_CHOSEN'; + if (turnOf(s, player).option !== 'draw') return 'OPTION_NOT_CHOSEN'; + if (turnOf(s, player).drawnThisTurn) return 'OPTION_ALREADY_CHOSEN'; if (s.decks.homeOffice.length === 0) return 'DECK_EMPTY'; return null; case 'draw.fromDepartment': if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; - if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN'; - if (s.turn.drawnThisTurn) return 'OPTION_ALREADY_CHOSEN'; + if (turnOf(s, player).option !== 'draw') return 'OPTION_NOT_CHOSEN'; + if (turnOf(s, player).drawnThisTurn) return 'OPTION_ALREADY_CHOSEN'; if (i.slot < 0 || i.slot > 2) return 'SLOT_EMPTY'; return (s.decks.departments[i.slot]?.length ?? 0) > 0 ? null : 'SLOT_EMPTY'; case 'card.play': { if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; - if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN'; + if (turnOf(s, player).option !== 'draw') return 'OPTION_NOT_CHOSEN'; const hand = s.decks.hands.get(player) ?? []; if (!hand.includes(i.cardId)) return 'CARD_NOT_IN_HAND'; return checkPlay(s, player, i.cardId, i.placement, i.variant, i.node); @@ -608,7 +628,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo case 'mainline.modify': { if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; - if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN'; + if (turnOf(s, player).option !== 'draw') return 'OPTION_NOT_CHOSEN'; const card = s.cards.get(i.cardId); if (!card || !(s.decks.hands.get(player) ?? []).includes(i.cardId)) return 'NO_SUCH_CARD'; if (card.kind.kind !== 'mainlineModifier') return 'WRONG_INTENT'; @@ -646,8 +666,8 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo case 'maneuver.flyingSwitch': { if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; - if (s.turn.option !== 'switch') return 'OPTION_NOT_CHOSEN'; - if (s.turn.movesRemaining < 1) return 'NO_MOVES_REMAINING'; + if (turnOf(s, player).option !== 'switch') return 'OPTION_NOT_CHOSEN'; + if (turnOf(s, player).movesRemaining < 1) return 'NO_MOVES_REMAINING'; const card = s.cards.get(i.cardId); if (!card || !(s.decks.hands.get(player) ?? []).includes(i.cardId)) return 'NO_SUCH_CARD'; if (card.kind.kind !== 'maneuver' || card.kind.key !== 'flyingSwitch') return 'WRONG_INTENT'; @@ -677,7 +697,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo case 'draw.end': { if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; - if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN'; + if (turnOf(s, player).option !== 'draw') return 'OPTION_NOT_CHOSEN'; // §6.2 — "the player must reduce his hand to no more than three cards". const hand = s.decks.hands.get(player) ?? []; const limit = s.decks.redFlags.get(player) ? HAND_LIMIT + 1 : HAND_LIMIT; @@ -687,8 +707,8 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo // -- Freight Agent -------------------------------------------------------- case 'freightAgent.stockOutbound': { if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; - if (s.turn.option !== 'freightAgent') return 'OPTION_NOT_CHOSEN'; - if (s.turn.freightAgentUsed) return 'OPTION_ALREADY_CHOSEN'; + if (turnOf(s, player).option !== 'freightAgent') return 'OPTION_NOT_CHOSEN'; + if (turnOf(s, player).freightAgentUsed) return 'OPTION_ALREADY_CHOSEN'; const f = facilityAt(s, player, i.at); if (!f) return 'NO_SUCH_FACILITY'; if (!f.allows.outbound) return 'NO_SUCH_FACILITY'; @@ -703,8 +723,8 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo case 'freightAgent.clearInbound': { if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; - if (s.turn.option !== 'freightAgent') return 'OPTION_NOT_CHOSEN'; - if (s.turn.freightAgentUsed) return 'OPTION_ALREADY_CHOSEN'; + if (turnOf(s, player).option !== 'freightAgent') return 'OPTION_NOT_CHOSEN'; + if (turnOf(s, player).freightAgentUsed) return 'OPTION_ALREADY_CHOSEN'; const f = facilityAt(s, player, i.at); if (!f) return 'NO_SUCH_FACILITY'; return f.inboundBox[i.index] ? null : 'BOX_EMPTY'; @@ -715,12 +735,12 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo // §6.3 offers three things the Freight Agent may do and requires none of them. Ending with the // action unspent is a wasted Stage, which is the player's to waste — the alternative was // forcing an unjam that destroys a load. - return s.turn.option === 'freightAgent' ? null : 'OPTION_NOT_CHOSEN'; + return turnOf(s, player).option === 'freightAgent' ? null : 'OPTION_NOT_CHOSEN'; case 'freightAgent.unjam': { if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; - if (s.turn.option !== 'freightAgent') return 'OPTION_NOT_CHOSEN'; - if (s.turn.freightAgentUsed) return 'OPTION_ALREADY_CHOSEN'; + if (turnOf(s, player).option !== 'freightAgent') return 'OPTION_NOT_CHOSEN'; + if (turnOf(s, player).freightAgentUsed) return 'OPTION_ALREADY_CHOSEN'; const f = facilityAt(s, player, i.at); if (!f) return 'NO_SUCH_FACILITY'; if (i.from === 'menAtWork') return f.menAtWork?.[i.index] ? null : 'BOX_EMPTY'; @@ -1051,7 +1071,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] { trayId: i.trayId, from, to: i.to, - movesRemaining: s.turn.movesRemaining - 1, + movesRemaining: turnOf(s, player).movesRemaining - 1, /** * A TRAIN THAT BACKS UP HAS NOT TURNED AROUND. * @@ -1366,19 +1386,22 @@ function findTimetableSlot(s: GameState, from: number): number | null { export function reduce(s: GameState, e: GameEvent): void { switch (e.type) { case 'localOpsOptionChosen': - s.turn.option = e.option; + turnOf(s, e.player).option = e.option; break; case 'trayMoved': { const tray = s.trays.get(e.trayId)!; - const owner = tray.position.at === 'grid' ? tray.position.owner : 0; - tray.position = { at: 'grid', owner, coord: e.to }; + // A tray moving stays in the district it was already in — the seat does not change. + const seat = tray.position.at === 'grid' ? tray.position.seat : 0; + tray.position = { at: 'grid', seat, coord: e.to }; if (e.facing) tray.facing = e.facing; - s.turn.movesRemaining = e.movesRemaining; + // Only the player sitting in this district can be switching this tray, so the Moves come off + // their turn. The event carries no player of its own. + turnOf(s, playerAtSeat(s, seat)).movesRemaining = e.movesRemaining; // An A/D track is held only while the train is actually standing at the Office (§2.1). // Leaving it out of sync means the Office looks permanently full and every arrival collides. - const area = areaOf(s, owner); + const area = areaAtSeat(s, seat); const atOffice = e.to.row === area.officeCoord.row && e.to.col === area.officeCoord.col; area.adOccupancy = area.adOccupancy.filter((t) => t !== e.trayId); @@ -1388,7 +1411,7 @@ export function reduce(s: GameState, e: GameEvent): void { case 'carsCoupled': { const tray = s.trays.get(e.trayId)!; - const area = areaOf(s, tray.position.at === 'grid' ? tray.position.owner : 0); + const area = areaAtSeat(s, tray.position.at === 'grid' ? tray.position.seat : 0); if (e.toNose) { // Cars taken on the nose go AHEAD of the engine — "pushing them into the Facility" — so the // engine is no longer at the front and its index has to follow. It never did, so a crew that @@ -1407,7 +1430,7 @@ export function reduce(s: GameState, e: GameEvent): void { card.standing = []; if (card.facility) card.facility.industryTrack.cars = []; } - spendFreightBudget(s, tray, e.at, e.stock); + spendFreightBudget(s, playerAtSeat(s, trayySeat(tray)), tray, e.at, e.stock); break; } @@ -1418,13 +1441,14 @@ export function reduce(s: GameState, e: GameEvent): void { // to use one: §8.2 will not let a train leave the Office with cars in front of its engine. tray.engineAt = 0; // "Spends one move in the yard" — the sort costs a Move. - s.turn.movesRemaining = Math.max(0, s.turn.movesRemaining - 1); + const sorter = turnOf(s, playerAtSeat(s, trayySeat(tray))); + sorter.movesRemaining = Math.max(0, sorter.movesRemaining - 1); break; } case 'carsDropped': { const tray = s.trays.get(e.trayId)!; - const area = areaOf(s, tray.position.at === 'grid' ? tray.position.owner : 0); + const area = areaAtSeat(s, tray.position.at === 'grid' ? tray.position.seat : 0); if (e.fromNose) { // Off the front: everything ahead of the engine shortens, so the engine moves up by that // much. This is how a train that took cars onto its nose gets back to being made up. @@ -1437,7 +1461,7 @@ export function reduce(s: GameState, e: GameEvent): void { const card = area.grid.get(coordKey(e.at)); // On a Facility card the industry track is where cars stand (§9.3). if (card) carsOn(card).push(...e.stock); - spendFreightBudget(s, tray, e.at, e.stock); + spendFreightBudget(s, playerAtSeat(s, trayySeat(tray)), tray, e.at, e.stock); break; } @@ -1469,7 +1493,7 @@ export function reduce(s: GameState, e: GameEvent): void { else if (e.slot !== undefined) s.decks.departments[e.slot]!.pop(); hand.push(e.cardId); s.decks.hands.set(e.player, hand); - s.turn.drawnThisTurn = true; + turnOf(s, e.player).drawnThisTurn = true; break; } @@ -1532,7 +1556,7 @@ export function reduce(s: GameState, e: GameEvent): void { if (track) track.cars.push(...e.stock); else card.standing.push(...e.stock); } - s.turn.movesRemaining -= 1; + turnOf(s, e.player).movesRemaining -= 1; spendCard(s, e.player, e.cardId); break; } @@ -1588,7 +1612,7 @@ export function reduce(s: GameState, e: GameEvent): void { if (idx >= 0) s.yards.divisionYard.splice(idx, 1); refillDivisionYardIfEmpty(s); f.outboundBox.push(e.stock); - s.turn.freightAgentUsed = true; + turnOf(s, e.player).freightAgentUsed = true; break; } @@ -1597,7 +1621,7 @@ export function reduce(s: GameState, e: GameEvent): void { const idx = f.inboundBox.findIndex((c) => c.type === e.stock.type && c.loaded === e.stock.loaded); if (idx >= 0) f.inboundBox.splice(idx, 1); s.yards.classificationYard.push(e.stock); - s.turn.freightAgentUsed = true; + turnOf(s, e.player).freightAgentUsed = true; break; } @@ -1612,7 +1636,7 @@ export function reduce(s: GameState, e: GameEvent): void { if (idx >= 0) box.splice(idx, 1); } s.yards.classificationYard.push(e.stock); - s.turn.freightAgentUsed = true; + turnOf(s, e.player).freightAgentUsed = true; break; } @@ -1753,7 +1777,7 @@ export function reduce(s: GameState, e: GameEvent): void { case 'phaseEnded': // The actor has finished; the phase driver moves on to the next player. - if (e.phase !== 'redFlag') s.turn.done = true; + if (e.phase !== 'redFlag') turnOf(s, e.player).done = true; break; case 'clearanceGiven': diff --git a/src/engine/legal.ts b/src/engine/legal.ts index 45200e9..95d6c1a 100644 --- a/src/engine/legal.ts +++ b/src/engine/legal.ts @@ -17,6 +17,7 @@ import { enhancementRule } from './content.ts'; import { check, areaOf, destinationsFor } from './apply.ts'; import type { Intent } from './intents.ts'; import type { GameState, GridCoord, PlayerIndex } from './state.ts'; +import { seatOf } from './state.ts'; import { variantsFor } from './track.ts'; const CAR_TYPES: readonly CarType[] = ['coach', 'boxcar', 'reefer', 'hopper', 'tank', 'caboose']; @@ -82,7 +83,7 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] { // -- switch (§6.1) for (const [trayId, tray] of s.trays) { - if (tray.position.at !== 'grid' || tray.position.owner !== player) continue; + if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue; const from = tray.position.coord; for (const reverse of [false, true]) { for (const d of destinationsFor(s, player, trayId, from, reverse)) { @@ -113,7 +114,7 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] { const k = s.cards.get(cardId)?.kind; if (k?.kind !== 'maneuver' || k.key !== 'flyingSwitch') continue; for (const [trayId, tray] of s.trays) { - if (tray.position.at !== 'grid' || tray.position.owner !== player) continue; + if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue; const from = tray.position.coord; for (const reverse of [false, true]) { for (const d of destinationsFor(s, player, trayId, from, reverse)) { diff --git a/src/engine/setup.ts b/src/engine/setup.ts index 0d62fc6..2d51ed9 100644 --- a/src/engine/setup.ts +++ b/src/engine/setup.ts @@ -34,6 +34,7 @@ import type { Card, CardId, DivisionNode, + SeatIndex, GameConfig, GameState, OfficeArea, @@ -42,7 +43,7 @@ import type { TrackCard, TrayId, } from './state.ts'; -import { coordKey, freshTurn } from './state.ts'; +import { coordKey, freshTurns } from './state.ts'; export type SetupOptions = { id: string; @@ -129,7 +130,7 @@ export function buildRollingStock(): RollingStock[] { * branch from the opening Stage. The stubs are NOT turnouts: §A.1's directional rule governs * drawn turnout cards only. */ -function buildOfficeArea(owner: PlayerIndex): OfficeArea { +function buildOfficeArea(seat: SeatIndex): OfficeArea { const row = 0; const officeCoord = { row, col: 0 }; const limitsWest = { row, col: -1 }; @@ -159,7 +160,7 @@ function buildOfficeArea(owner: PlayerIndex): OfficeArea { grid.set(coordKey(limitsEast), limitsCard()); return { - owner, + seat, tier: 'whistlePost', grid, officeCoord, @@ -230,7 +231,7 @@ function buildDivision(players: number, rng: Rng): DivisionNode[] { nodes.push({ kind: 'divisionPoint', side: 'west', holding: [] }); for (let p = 0; p < players; p++) { nodes.push(mainline()); - nodes.push({ kind: 'office', owner: p }); + nodes.push({ kind: 'office', seat: p }); } nodes.push(mainline()); nodes.push({ kind: 'divisionPoint', side: 'east', holding: [] }); @@ -250,8 +251,12 @@ export function createGame(opts: SetupOptions): GameState { const players = playerNames.map((name, index) => ({ index, name, revenue: 0 })); - const officeAreas = new Map(); - for (let p = 0; p < playerCount; p++) officeAreas.set(p, buildOfficeArea(p)); + // Offices are keyed by SEAT — a fixed position in the west-to-east chain. `seating` maps seats to + // the players occupying them, and starts as the identity mapping, which is what makes the + // seat/player split behaviour-neutral. Employee Rotation would rotate this array and nothing else. + const officeAreas = new Map(); + for (let seat = 0; seat < playerCount; seat++) officeAreas.set(seat, buildOfficeArea(seat)); + const seating: PlayerIndex[] = Array.from({ length: playerCount }, (_, seat) => seat); // §4.4 - highest D12 takes the Eastern Division Point; §4.5 - highest begins as Superintendent. // Both rolls are drawn even in solitaire so the RNG stream stays identical across player counts. @@ -334,6 +339,7 @@ export function createGame(opts: SetupOptions): GameState { seed, rngState: rng.getState(), players, + seating, division: { nodes: buildDivision(playerCount, rng) }, officeAreas, trays: new Map(), @@ -354,7 +360,7 @@ export function createGame(opts: SetupOptions): GameState { superintendent, actorOffset: 0, }, - turn: freshTurn(MOVES_PER_LOCAL_OPS), + turns: freshTurns(playerCount, MOVES_PER_LOCAL_OPS), movedThisPhase: new Set(), collisionsToday: 0, status: 'active', diff --git a/src/engine/state.ts b/src/engine/state.ts index a106536..b4cc3be 100644 --- a/src/engine/state.ts +++ b/src/engine/state.ts @@ -24,6 +24,22 @@ import { officeProfile } from './content.ts'; // --------------------------------------------------------------------------- export type PlayerIndex = number; + +/** + * A SEAT at the table — a fixed position in the west-to-east chain of Offices (§4.3). + * + * NOT the same thing as a `PlayerIndex`, even though the two are equal in every game today. An + * Office is a place: it sits between two Mainline cards and never moves. A player OCCUPIES a seat, + * and Employee Rotation (Appendix B) moves every player one seat left at the end of each Day while + * their Revenue and the Fedora travel with them. + * + * So: offices, districts and grid positions are keyed by SEAT; hands, Revenue, the Superintendent + * and whose turn it is are keyed by PLAYER. `s.seating` maps one to the other, and both are plain + * numbers, so the distinction is carried by naming and by the accessors rather than by the type + * system — `areaOf(s, player)` and `areaAtSeat(s, seat)` are the two doors, and code should use them + * rather than reaching into `officeAreas` directly. + */ +export type SeatIndex = number; export type CardId = string; export type TrayId = string; @@ -111,7 +127,8 @@ export type TrackCard = { }; export type OfficeArea = { - owner: PlayerIndex; + /** Where this Office sits in the chain, not who is sitting at it. See `SeatIndex`. */ + seat: SeatIndex; tier: OfficeTier; grid: Map; officeCoord: GridCoord; @@ -223,7 +240,8 @@ export function isLockedByWork(card: TrackCard): boolean { export type NodeRef = | { at: 'divisionPoint'; side: Direction } | { at: 'mainline'; index: number } - | { at: 'grid'; owner: PlayerIndex; coord: GridCoord }; + /** A tray standing in someone's district. `seat` is WHICH district, not whose turn it is. */ + | { at: 'grid'; seat: SeatIndex; coord: GridCoord }; export type CrewTray = { id: TrayId; @@ -328,7 +346,7 @@ export type DivisionNode = /** Red Flags protecting a stopped train here, by tray. */ redFlagged?: TrayId[]; } - | { kind: 'office'; owner: PlayerIndex }; + | { kind: 'office'; seat: SeatIndex }; /** Ordered west to east. For N players: N Office nodes and N+1 Mainline cards. */ export type Division = { nodes: DivisionNode[] }; @@ -482,6 +500,30 @@ export type TurnState = { done: boolean; }; +/** + * One turn per player, created together at phase entry. + * + * This was a single `TurnState` on the game, replaced one player at a time as the cursor walked the + * table. That is indistinguishable from this while only the player at the cursor may act — which is + * exactly the case today, and every existing test still describes the same game. + * + * It is per-player now because making it so later would mean the same change PLUS reworking a client + * built around "wait your turn". What it enables is Local Operations work that no other player can + * observe — switching inside your own district — happening off-cursor, which is a change to + * `isActor` and nothing else. See `docs/architecture/multiplayer.md` D19. + */ +export function freshTurns(players: number, moves: number): Map { + const turns = new Map(); + for (let p = 0; p < players; p++) turns.set(p, freshTurn(moves)); + return turns; +} + +export function turnOf(s: GameState, player: PlayerIndex): TurnState { + const t = s.turns.get(player); + if (!t) throw new Error(`no turn state for player ${player}`); + return t; +} + export function freshTurn(moves: number): TurnState { return { option: null, @@ -500,8 +542,15 @@ export type GameState = { seed: number; rngState: number; players: Player[]; + /** + * Who is sitting where: `seating[seat] = player`. + * + * 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. + */ + seating: PlayerIndex[]; division: Division; - officeAreas: Map; + officeAreas: Map; trays: Map; /** Trays not yet in play; §7 scarcity is an explicit mechanic. */ freeTrays: TrayId[]; @@ -521,7 +570,8 @@ export type GameState = { */ pendingSecondSections: number[]; clock: Clock; - turn: TurnState; + /** One per player, keyed by PLAYER (a turn belongs to a person, not to a chair). */ + turns: Map; /** Transient: trains already moved in the current Mainline Phase. Cleared when it ends. */ movedThisPhase: Set; /** §3.4 — resets at the start of each Day. */ @@ -551,7 +601,7 @@ export function subdivisions(state: GameState): number[][] { state.division.nodes.forEach((node, i) => { const isBoundary = node.kind === 'divisionPoint' || - (node.kind === 'office' && isControlPoint(state, node.owner)); + (node.kind === 'office' && isControlPoint(state, node.seat)); if (isBoundary) { if (current.length > 0) out.push(current); @@ -565,15 +615,29 @@ export function subdivisions(state: GameState): number[][] { return out; } -export function isControlPoint(state: GameState, owner: PlayerIndex): boolean { - const area = state.officeAreas.get(owner); - if (!area) throw new Error(`no Office Area for player ${owner}`); +/** Who is sitting at this seat. */ +export function playerAtSeat(state: GameState, seat: SeatIndex): PlayerIndex { + const p = state.seating[seat]; + if (p === undefined) throw new Error(`no player at seat ${seat}`); + return p; +} + +/** Where this player is sitting, and therefore which Office Area is theirs. */ +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`); + return seat; +} + +export function isControlPoint(state: GameState, seat: SeatIndex): boolean { + const area = state.officeAreas.get(seat); + if (!area) throw new Error(`no Office Area at seat ${seat}`); return officeProfile(area.tier).isControlPoint; } -export function adTrackCount(state: GameState, owner: PlayerIndex): number { - const area = state.officeAreas.get(owner); - if (!area) throw new Error(`no Office Area for player ${owner}`); +export function adTrackCount(state: GameState, seat: SeatIndex): number { + const area = state.officeAreas.get(seat); + if (!area) throw new Error(`no Office Area at seat ${seat}`); return officeProfile(area.tier).adTracks; } diff --git a/src/sim/board-svg.ts b/src/sim/board-svg.ts index 1da11db..4f4eaa0 100644 --- a/src/sim/board-svg.ts +++ b/src/sim/board-svg.ts @@ -84,7 +84,8 @@ export function divisionSvg(nodes: DivisionView[]): string { }[]; cap: number | null; tip: string; - owner: number | null; + /** Which SEAT's district this cell belongs to, or null for Mainline and Division Points. */ + seat: number | null; /** Mainline cards only: §2.1 divides one into two regions. 0 elsewhere — no bars are drawn. */ regions: number; w: number; @@ -120,7 +121,7 @@ export function divisionSvg(nodes: DivisionView[]): string { : rc.trains, cap: isOffice ? cap : null, tip: `${rc.label} — ${rc.kind === 'limits' ? 'the end of this district; the Running Track runs between the Limits' : 'Running Track'}`, - owner: n.owner ?? null, + seat: n.seat ?? null, // No regions inside a district: a crew moves by Moves there, not by Stages, so it // occupies a card outright rather than a part of one. regions: 0, @@ -148,7 +149,7 @@ export function divisionSvg(nodes: DivisionView[]): string { tip: dp ? 'A Division Point — the end of the line. Trains both enter and leave the Division here (odd numbers run west, even run east), and queue without limit' : `${n.label} — Mainline${n.gradeUp ? `, climbs ${n.gradeUp === 'east' ? 'east' : 'west'}` : ''}${n.modifiers.length ? ` · ${n.modifiers.join(' · ')}` : ''}`, - owner: null, + seat: null, // A Division Point is one region — the queue trains enter and leave the Division through. regions: dp ? 1 : (n.regions ?? 0), w: dp ? CW.dp : CW.ml, diff --git a/src/sim/bot.ts b/src/sim/bot.ts index 1d869bb..42a2816 100644 --- a/src/sim/bot.ts +++ b/src/sim/bot.ts @@ -27,7 +27,7 @@ import type { Intent } from '../engine/intents.ts'; import { legalActions } from '../engine/legal.ts'; import { connectionsFor, exitsFrom, facilityVariants, hasPort, joins, neighbour, opposite, variantsFor } from '../engine/track.ts'; import type { Port } from '../engine/track.ts'; -import { coordKey } from '../engine/state.ts'; +import { coordKey, turnOf } from '../engine/state.ts'; import type { Facility, GameState, GridCoord, OfficeArea, PlayerIndex, RollingStock, TrackCard } from '../engine/state.ts'; export type BotPolicy = { @@ -206,7 +206,7 @@ export function makeDeveloperBot(tweaks: BotTweaks): BotPolicy { ? options.filter((i) => !(i.type === 'card.play' && isTrainCard(s, i.cardId))) : options; const usable = held.length > 0 ? held : options; - if (s.turn.option === null) return chooseLocalOption(s, player, usable, tweaks); + if (turnOf(s, player).option === null) return chooseLocalOption(s, player, usable, tweaks); return followThrough(s, player, usable, tweaks); } @@ -339,7 +339,7 @@ function chooseLocalOption( * NO BRANCH FOR MAINLINE MODIFIERS, and that is the measured answer rather than an oversight. * * There was one. It read `options.some((i) => i.type === 'mainline.modify')`, but `mainline.modify` - * requires `s.turn.option === 'draw'` and this runs while the option is still null, so + * requires `turnOf(s, player).option === 'draw'` and this runs while the option is still null, so * `legalActions` had already filtered it out — the branch could never fire and never had. * * Rewriting it to check the HAND made it work, and made the bot WORSE: -0.64 revenue a game, @@ -402,7 +402,7 @@ function topOfDepartment(s: GameState, slot: number): string | undefined { * In a competitive game the same call reads the other way round — burying a card a rival wants is an * attack — which is why the choice belongs to the discarding player and not to the rules. */ -function bestDiscard(s: GameState, options: Intent[]): Intent | null { +function bestDiscard(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null { let best: Intent | null = null; let bestScore = -Infinity; for (const i of options) { @@ -412,7 +412,7 @@ function bestDiscard(s: GameState, options: Intent[]): Intent | null { // two showing whatever they happened to start with. Measured over 100 games — spreading 2.87 // revenue, concentrating on the deepest 2.67, indifferent 2.67. const top = topOfDepartment(s, i.toSlot); - const wanted = isWorthTaking(s, i.toSlot); + const wanted = isWorthTaking(s, player, i.toSlot); const depth = s.decks.departments[i.toSlot]?.length ?? 0; const score = (top === undefined ? 6 : wanted ? -10 : 2) - Math.min(depth, 4) * 0.5; if (score > bestScore) { @@ -424,8 +424,8 @@ function bestDiscard(s: GameState, options: Intent[]): Intent | null { } /** A face-up card worth spending the draw on rather than gambling on the deck. */ -function isWorthTaking(s: GameState, slot: number): boolean { - return takingRank(s, slot) > 0; +function isWorthTaking(s: GameState, player: PlayerIndex, slot: number): boolean { + return takingRank(s, player, slot) > 0; } /** @@ -435,12 +435,12 @@ function isWorthTaking(s: GameState, slot: number): boolean { * happened to be scanned first — a coin flip on the card that decides whether the district ever * becomes a Passenger Facility at all. */ -function takingRank(s: GameState, slot: number): number { +function takingRank(s: GameState, player: PlayerIndex, slot: number): number { const id = topOfDepartment(s, slot); if (!id) return 0; const k = s.cards.get(id)?.kind; if (!k) return 0; - if (k.kind === 'office') return nextOfficeTier(areaOf(s, 0).tier) === k.tier ? 3 : 0; + if (k.kind === 'office') return nextOfficeTier(areaOf(s, player).tier) === k.tier ? 3 : 0; if (k.kind === 'timetabledTrain' || k.kind === 'extraTrain') return 2; if (k.kind === 'freightFacility') return 1; return 0; @@ -646,8 +646,7 @@ function runAroundCells(area: OfficeArea): Set { * there is one the crew dare not serve. */ function bestFacilityPlay(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null { - const area = s.officeAreas.get(player); - if (!area) return null; + const area = areaOf(s, player); const onLoop = runAroundCells(area); const reachable = reachableOffMain(area); @@ -703,8 +702,7 @@ function bestFacilityPlay(s: GameState, player: PlayerIndex, options: Intent[]): } function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null { - const area = s.officeAreas.get(player); - if (!area) return null; + const area = areaOf(s, player); const at = (row: number, col: number): TrackCard | undefined => area.grid.get(`${row},${col}`); @@ -1125,17 +1123,17 @@ function followThrough( options: Intent[], tweaks: BotTweaks, ): Intent { - switch (s.turn.option) { + switch (turnOf(s, player).option) { case 'draw': { // Draw before playing — otherwise the hand empties and never refills. // // Prefer a face-up Department card only when it is actually worth having. Taking the visible // card unconditionally meant the bot never once drew blind from the deck across 60 games, // which the anomaly detector correctly flagged: a whole branch of §6.2 going unexercised. - if (!s.turn.drawnThisTurn) { + if (!turnOf(s, player).drawnThisTurn) { const piles = options.filter( (i): i is Extract => - i.type === 'draw.fromDepartment' && isWorthTaking(s, i.slot), + i.type === 'draw.fromDepartment' && isWorthTaking(s, player, i.slot), ); // Best-ranked pile rather than the first that qualifies: an Office card and a train card // both "qualify", and only one of them stops the collisions. @@ -1143,7 +1141,7 @@ function followThrough( // and a train card are face up together 1.6 decisions a game — but ranking them is what the // ranking function is for, and a coin flip on the card that decides whether the district // ever becomes a Passenger Facility is not worth keeping for its own sake. - const useful = piles.sort((a, b) => takingRank(s, b.slot) - takingRank(s, a.slot))[0]; + const useful = piles.sort((a, b) => takingRank(s, player, b.slot) - takingRank(s, player, a.slot))[0]; if (useful) return because('a face-up card is worth more than a blind draw right now', useful); const blind = options.find((i) => i.type === 'draw.fromHomeOffice'); if (blind) return because('no face-up card is worth taking — gamble on the deck', blind); @@ -1235,7 +1233,7 @@ function followThrough( if (end) return because('nothing in hand can be played anywhere legal', end); return because( 'nothing playable — discard onto the Department whose face-up card is least worth keeping reachable', - bestDiscard(s, options) ?? pickFirst(options, 'card.discard') ?? options[0]!, + bestDiscard(s, player, options) ?? pickFirst(options, 'card.discard') ?? options[0]!, ); } @@ -1654,14 +1652,14 @@ function strandedWantedCars(s: GameState, player: PlayerIndex): { row: number; c function trayOf(s: GameState, player: PlayerIndex) { for (const tray of s.trays.values()) { - if (tray.position.at === 'grid' && tray.position.owner === player) return tray; + if (tray.position.at === 'grid' && tray.position.seat === player) return tray; } return null; } function trayLocation(s: GameState, player: PlayerIndex): { row: number; col: number } | null { for (const tray of s.trays.values()) { - if (tray.position.at === 'grid' && tray.position.owner === player) { + if (tray.position.at === 'grid' && tray.position.seat === player) { return tray.position.coord; } } diff --git a/src/sim/narrate.ts b/src/sim/narrate.ts index 2370e5d..614e8b6 100644 --- a/src/sim/narrate.ts +++ b/src/sim/narrate.ts @@ -15,9 +15,9 @@ * panel cannot drift from the rules. */ -import { adTrackCount, coordKey } from '../engine/state.ts'; -import type { GameState, GridCoord, RollingStock, TrayId } from '../engine/state.ts'; -import { canAdvanceLoad, canStartLoad, facilityCarType, laborersLeft, movesFor, portersLeft } from '../engine/apply.ts'; +import { adTrackCount, coordKey, seatOf, turnOf } from '../engine/state.ts'; +import type { GameState, GridCoord, PlayerIndex, RollingStock, TrayId } from '../engine/state.ts'; +import { areaOf, canAdvanceLoad, canStartLoad, facilityCarType, laborersLeft, movesFor, portersLeft } from '../engine/apply.ts'; import type { GameEvent } from '../engine/events.ts'; // --------------------------------------------------------------------------- @@ -446,10 +446,9 @@ export type Impediment = { where: string; why: string; severity: 'stuck' | 'wait * This is the panel that should answer the standing questions: whether facilities jam, whether * trains are held for want of a crew, whether the Office is about to cause a collision. */ -export function impediments(s: GameState, player = 0): Impediment[] { +export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[] { const out: Impediment[] = []; - const area = s.officeAreas.get(player); - if (!area) return out; + const area = areaOf(s, player); for (const [key, card] of area.grid) { const f = card.facility; @@ -512,9 +511,10 @@ export function impediments(s: GameState, player = 0): Impediment[] { * Only while switching, and only the cards actually in the crew's way: `movesFor` reports the * squares the movement walk reached and refused, not every square on the board. */ - if (s.clock.phase === 'localOps' && s.turn.option === 'switch' && s.turn.movesRemaining > 0) { + const turn = turnOf(s, player); + if (s.clock.phase === 'localOps' && turn.option === 'switch' && turn.movesRemaining > 0) { for (const [id, tray] of s.trays) { - if (tray.position.at !== 'grid' || tray.position.owner !== player) continue; + if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue; const { blocked } = movesFor(s, player, id); for (const b of blocked) { // A turnout is not an obstruction — a train runs through one all day and simply may not @@ -583,7 +583,7 @@ export function impediments(s: GameState, player = 0): Impediment[] { } // A full Office means the next arrival is an automatic collision (Gap 2d). - const cap = adTrackCount(s, player); + const cap = adTrackCount(s, seatOf(s, player)); if (area.adOccupancy.length >= cap) { out.push({ where: 'Office', diff --git a/src/sim/save-replay.ts b/src/sim/save-replay.ts index 39fa643..92ced30 100644 --- a/src/sim/save-replay.ts +++ b/src/sim/save-replay.ts @@ -25,6 +25,7 @@ import { writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { areaAtSeat } from '../engine/apply.ts'; import { legalActions } from '../engine/legal.ts'; import type { BotPolicy } from './bot.ts'; import { developerBot, makeDeveloperBot } from './bot.ts'; @@ -71,7 +72,7 @@ export function playForReplay(seed: number, policy: BotPolicy, maxTurns = 50_000 save: toSave(game), note: `${revenue} Revenue over ${game.state.clock.day - 1} Days · ${trains} train(s) on the timetable · ` + - `${game.state.officeAreas.get(0)?.grid.size ?? 0} cards down` + + `${areaAtSeat(game.state, 0).grid.size} cards down` + (collisions > 0 ? ` · ${collisions} collision(s)` : ' · no collisions'), }; } diff --git a/src/sim/stats.ts b/src/sim/stats.ts index 095f50a..239f616 100644 --- a/src/sim/stats.ts +++ b/src/sim/stats.ts @@ -18,7 +18,7 @@ import type { GameEvent } from '../engine/events.ts'; import type { Intent } from '../engine/intents.ts'; import type { GameState } from '../engine/state.ts'; -import { areaOf, facilityCarTypes } from '../engine/apply.ts'; +import { areaAtSeat, areaOf, facilityCarTypes } from '../engine/apply.ts'; import { officeProfile } from '../engine/content.ts'; // --------------------------------------------------------------------------- @@ -121,7 +121,7 @@ export function makeFunnelProbe(player = 0): { // A buried engine is a per-decision condition: every turn it persists is a turn the train is // stuck, so this counts turns rather than trains. for (const tray of s.trays.values()) { - if (tray.position.at !== 'grid' || tray.position.owner !== player) continue; + if (tray.position.at !== 'grid' || tray.position.seat !== player) continue; if (tray.engineAt <= 0 || tray.engineAt >= tray.consist.length) continue; funnel.buriedTurns += 1; // Cars may never be set out at the Office (§A.4), which is where this almost always happens. @@ -295,7 +295,10 @@ export function summarize( const grossPassenger = rev.passengerBoard + rev.passengerDetrain; const gross = grossFreight + grossPassenger; - const area = final.officeAreas.get(0); + // Seat 0 explicitly: these are SOLITAIRE summaries, where there is exactly one Office Area and + // seat 0 is the only player. `areaAtSeat` rather than `officeAreas.get` so the key's meaning is + // stated — a multi-player report would have to sum over seats, and would fail loudly here first. + const area = areaAtSeat(final, 0); let freightFacilities = 0; if (area) { for (const card of area.grid.values()) { diff --git a/src/sim/view.ts b/src/sim/view.ts index ca716d9..3e77738 100644 --- a/src/sim/view.ts +++ b/src/sim/view.ts @@ -11,6 +11,7 @@ */ import { + areaAtSeat, areaOf, destinationsFor, facilityCarType, @@ -40,6 +41,7 @@ import { } from '../engine/content.ts'; import type { Intent } from '../engine/intents.ts'; import type { Facility, GameState, PlayerIndex, TrackCard, TurnoutOrientation } from '../engine/state.ts'; +import { playerAtSeat, seatOf, turnOf } from '../engine/state.ts'; import type { Hand, TrackGeometry } from '../engine/content.ts'; import type { Port } from '../engine/track.ts'; import { connectionsFor, slopeOfPair, variantsFor } from '../engine/track.ts'; @@ -250,7 +252,8 @@ export type DivisionView = { /** Office nodes only: the Running Track, Limits to Limits, west to east. */ running?: RunningCardView[]; /** Office nodes only: whose district this is. */ - owner?: number; + /** Which SEAT's district this is — a position on the Division, not a player. */ + seat?: number; /** * Office nodes only: crews working BELOW the Running Track. * @@ -275,6 +278,21 @@ export type Frame = { actor: number | null; superintendent: number; revenue: number; + /** + * The rest of what a client needs so it never has to reach into `GameState`. + * + * The browser client used to read `game.state` in eleven places for exactly these. That is fine + * with the engine in the same process and impossible with a server, where the client holds no + * state at all — so they live on the projection instead. See `docs/architecture/multiplayer.md` §5. + */ + /** Which of §6's three exclusive options the VIEWER has taken this Stage, if any. */ + 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 }[]; + /** How many cards the VIEWER holds. Other players' counts are in `players`. */ + handCount: number; lines: { text: string; tone: string }[]; where: { row: number; col: number } | null; /** Origin of a Move, so the crew's journey is visible rather than a chip teleporting. */ @@ -548,7 +566,10 @@ function sampleDetail(s: GameState, kind: string, list: Intent[]): string { export function describeIntent(s: GameState, i: Intent): string { const at = (c: { row: number; col: number }): string => `(${c.row},${c.col})`; // An intent belongs to whoever is acting, so it is described against THEIR district. - const seat: PlayerIndex = s.clock.currentActor ?? 0; + // The acting PLAYER, not a seat — `describeIntent` describes an intent against the district of + // whoever is making it. Named `seat` once, and then used as an `officeAreas` key, which is the + // exact confusion the seat/player split exists to stop. + const actor: PlayerIndex = s.clock.currentActor ?? 0; switch (i.type) { case 'localOps.choose': // The most consequential decision of the Stage, and it was labelled "choose switch". Say what @@ -578,7 +599,7 @@ export function describeIntent(s: GameState, i: Intent): string { * square was not empty. The Limits sign is excluded: laying track there is ordinary growth. */ const over = i.placement - ? s.officeAreas.get(seat)?.grid.get(`${i.placement.row},${i.placement.col}`) + ? areaOf(s, actor).grid.get(`${i.placement.row},${i.placement.col}`) : undefined; const upgrade = over?.geometry.kind === 'track'; return ( @@ -616,7 +637,7 @@ export function describeIntent(s: GameState, i: Intent): string { const here = tray?.position.at === 'grid' ? tray.position.coord : null; let picks = ''; if (here) { - const dest = destinationsFor(s, tray!.position.at === 'grid' ? tray!.position.owner : 0, i.trayId, here, i.reverse) + const dest = destinationsFor(s, tray!.position.at === 'grid' ? playerAtSeat(s, tray!.position.seat) : 0, i.trayId, here, i.reverse) .find((d) => d.coord.row === i.to.row && d.coord.col === i.to.col); if (dest && dest.couples.length > 0) { picks = ` — couples ${carsLabel(dest.couples)} on the way${i.reverse ? ' (behind)' : ' (onto the nose)'}`; @@ -651,7 +672,7 @@ export function describeIntent(s: GameState, i: Intent): string { * waiting for a train that can carry it. For a passenger facility that load is passengers on * the platform. */ - const f = areaOf(s, seat).grid.get(`${i.at.row},${i.at.col}`)?.facility ?? null; + const f = areaOf(s, actor).grid.get(`${i.at.row},${i.at.col}`)?.facility ?? null; const where = f?.kind === 'passenger' ? 'onto the platform' : 'into the green Loading box'; return i.carType === 'coach' && f?.kind === 'passenger' ? `bring passengers ${where} at ${at(i.at)} — they wait there for a train with an empty coach` @@ -786,9 +807,14 @@ export function snapshot( viewer: PlayerIndex = 0, ): Frame { const area = areaOf(s, viewer); + const viewerSeat = seatOf(s, viewer); const trayAt = new Map(); for (const [id, tray] of s.trays) { - if (tray.position.at === 'grid') { + // KEYED BY COORDINATE, so it must be filtered by seat first. Every district uses the same + // (row, col) origin, so without this a crew standing at (0,1) in one player's Office Area is + // drawn onto (0,1) of every other player's board — the cells come from `area.grid`, which is + // the viewer's, but the train on them came from anybody's. + if (tray.position.at === 'grid' && tray.position.seat === viewerSeat) { const label = tray.trainNumber === null ? 'crew' : `T${tray.trainIsExtra ? 'X' : ''}${tray.trainNumber}`; const carrying = tray.consist.length ? ` [${tray.consist.map(carLabel).join(', ')}]` : ' [empty]'; trayAt.set(`${tray.position.coord.row},${tray.position.coord.col}`, label + carrying); @@ -906,14 +932,14 @@ export function snapshot( gradeUp: isGrade ? (n.gradeUp ?? 'east') : null, }; } - const oa = areaOf(s, n.owner); + const oa = areaAtSeat(s, n.seat); // Where every crew in this district actually is: on a Running Track card, or below it. const onRunning = new Map(); const below: TrainChip[] = []; for (const [id, tray] of s.trays) { const pos = tray.position; - if (pos.at !== 'grid' || pos.owner !== n.owner) continue; + if (pos.at !== 'grid' || pos.seat !== n.seat) continue; const c = trainChip(s, id); if (pos.coord.row === oa.runningRow) { const k = `${pos.coord.row},${pos.coord.col}`; @@ -954,7 +980,7 @@ export function snapshot( capacity: officeProfile(oa.tier).adTracks, modifiers: [], gradeUp: null, - owner: n.owner, + seat: n.seat, running, switching: below, }; @@ -968,7 +994,7 @@ export function snapshot( phaseKey: s.clock.phase, actor: s.clock.currentActor, superintendent: s.clock.superintendent, - revenue: s.players[0]?.revenue ?? 0, + revenue: s.players[viewer]?.revenue ?? 0, lines, where, whereFrom, @@ -1011,11 +1037,21 @@ export function snapshot( timetable: [...s.timetable], decision, wasted, - objective: objectiveOf(s), + option: turnOf(s, viewer).option, + status: s.status, + outcome: s.outcome, + players: s.players.map((p) => ({ + index: p.index, + name: p.name, + revenue: p.revenue, + hand: (s.decks.hands.get(p.index) ?? []).length, + })), + handCount: (s.decks.hands.get(viewer) ?? []).length, + objective: objectiveOf(s, viewer), runningRow: area.runningRow, - movesLeft: s.clock.phase === 'localOps' && s.turn.option === 'switch' ? s.turn.movesRemaining : null, - moves: switchingMoves(s, 0), - blocked: impediments(s, 0), + movesLeft: s.clock.phase === 'localOps' && turnOf(s, viewer).option === 'switch' ? turnOf(s, viewer).movesRemaining : null, + moves: switchingMoves(s, viewer), + blocked: impediments(s, viewer), trains: [...s.trays.values()].map((t) => ({ label: t.trainNumber === null ? 'local crew' : `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`, where: @@ -1410,10 +1446,10 @@ const SIMPLE_CARDS = [ ...ACTION_CARDS, ]; -/** The goal, and whether the current score is keeping up with the clock. */ -function objectiveOf(s: GameState): Frame['objective'] { +/** The goal, and whether the VIEWER's score is keeping up with the clock. */ +function objectiveOf(s: GameState, viewer: PlayerIndex): Frame['objective'] { const profile = lengthProfile(s.config.length); - const revenue = s.players[0]?.revenue ?? 0; + const revenue = s.players[viewer]?.revenue ?? 0; const daysLeft = Math.max(0, profile.days - s.clock.day + 1); const elapsed = profile.days - daysLeft + 1; // Straight-line pace: by the end of Day N you want N/days of the target. @@ -1505,10 +1541,11 @@ function countStock( * question the page does not yet ask. */ function switchingMoves(s: GameState, player: PlayerIndex): Frame['moves'] { - if (s.clock.phase !== 'localOps' || s.turn.option !== 'switch') return null; - if (s.turn.movesRemaining < 1) return null; + const turn = turnOf(s, player); + if (s.clock.phase !== 'localOps' || turn.option !== 'switch') return null; + if (turn.movesRemaining < 1) return null; for (const [id, tray] of s.trays) { - if (tray.position.at !== 'grid' || tray.position.owner !== player) continue; + if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue; const { to, blocked } = movesFor(s, player, id); return { from: tray.position.coord, to, blocked }; } diff --git a/src/web/game.ts b/src/web/game.ts index 9c82a43..0963436 100644 --- a/src/web/game.ts +++ b/src/web/game.ts @@ -64,7 +64,14 @@ export const SOLO_CONFIG: GameConfig = { export type ActionGroup = { kind: string; title: string; - actions: { index: number; label: string }[]; + /** + * `tip` is the card's own description, resolved HERE rather than by the page. + * + * The button label is short; the hover text used to be looked up with `cardDescription(state, id)` + * from the browser, which needs the whole `GameState`. A remote client has no state, so the menu + * carries it. See `docs/architecture/multiplayer.md` §5. + */ + actions: { index: number; label: string; tip?: string }[]; }; export type Game = { @@ -175,13 +182,15 @@ export function actionGroups(game: Game): { options: Intent[]; groups: ActionGro if (actor === null) return { options: [], groups: [] }; const options = legalActions(game.state, actor); - const byKind = new Map(); + const byKind = new Map(); options.forEach((intent, index) => { const label = describeIntent(game.state, intent); const list = byKind.get(intent.type) ?? []; + const cardId = 'cardId' in intent ? (intent as { cardId: string }).cardId : null; + const tip = cardId ? cardDescription(game.state, cardId) : undefined; // Orientation variants and duplicate copies describe identically; showing one is enough, and a // list of forty identical rows hides the real choice rather than presenting it. - if (!list.some((a) => a.label === label)) list.push({ index, label }); + if (!list.some((a) => a.label === label)) list.push({ index, label, ...(tip ? { tip } : {}) }); byKind.set(intent.type, list); }); diff --git a/src/web/main.ts b/src/web/main.ts index 8adcc9f..d0f953d 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -1,5 +1,5 @@ /** - * Browser entry point — wires the DOM to `game.ts`. + * Browser entry point — wires the DOM to a `Session`. * * Presentation only. Every question of what is legal, what it means, or what the board looks like * is answered by the engine or by the shared view helpers. @@ -7,26 +7,25 @@ import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts'; import { TURNCHART_CSS, turnChartHtml } from '../sim/turnchart.ts'; +import type { Frame } from '../sim/view.ts'; +import type { Menu, Save } from './game.ts'; import { PANEL_CSS, blockedHtml, facilitiesHtml, pilesHtml, timetableHtml, yardHtml } from './panels.ts'; import { TOOLTIP_CSS, installTooltips } from './tooltip.ts'; import { playCue } from './sound.ts'; -import type { Game } from './game.ts'; import { MOVES_PER_LOCAL_OPS } from '../engine/content.ts'; -import { cardDescription } from '../sim/view.ts'; -import { - actionMenu, - currentActor, - fromSave, - newGame, - submit, - toSave, - undo, - view, -} from './game.ts'; +import type { LocalSession } from './session.ts'; +import { createLocalSession } from './session.ts'; const SAVE_KEY = 'station-master.save.v1'; -let game: Game; +/** + * The game, behind the Session boundary. + * + * Typed as `LocalSession` because this page is the solitaire client and uses undo, local saves and + * new-game — all of which are local-only. The parts that draw and submit go through the plain + * `Session` surface, which is what a remote client will provide unchanged. + */ +let session: LocalSession; /** Which card or track piece is picked, waiting for a location. */ let selected: string | null = null; /** @@ -108,8 +107,8 @@ function piecePreview(links: string[], label: string): string { * Built by `turnChartHtml`, shared with both replay viewers, so all three screens report where you * are in the Day the same way and with the same violet highlight. It used to live here alone. */ -function renderTurnChart(f: ReturnType): void { - const actorName = f.actor === null ? null : (game.state.players[f.actor]?.name ?? null); +function renderTurnChart(f: Frame): void { + const actorName = f.actor === null ? null : (f.players[f.actor]?.name ?? null); $('turnchart').innerHTML = turnChartHtml(f, actorName); } @@ -117,23 +116,42 @@ function start(): void { const params = new URLSearchParams(location.search); const requested = params.get('seed'); + // A seed in the URL makes a game shareable and reproducible: same link, same deal. + const seed = requested !== null ? Number(requested) || 1 : Math.floor(Math.random() * 1e9); + session = createLocalSession(seed); + const saved = load(); - if (saved && requested === null) { - game = fromSave(saved); - // Restoring replays the whole history, which re-records every draw along the way. Nothing on - // this screen is news to the player who left it there, so the "new card" badge starts clear. - game.justDrawn = null; - } else { - // A seed in the URL makes a game shareable and reproducible: same link, same deal. - const seed = requested !== null ? Number(requested) || 1 : Math.floor(Math.random() * 1e9); - game = newGame(seed); - } + if (saved && requested === null) session.restore(saved); + + applyCapabilities(); + // Every render goes through the session, so the page redraws whenever the game says it changed — + // which is what a remote session will use to push. Locally it fires on each accepted intent. + session.subscribe(render); render(); } +/** + * Hide the controls this session does not offer. + * + * Undo, a local save and dealing a new game are all things only a local session can do — a server + * cannot un-see what the other players have already seen, the server is the store, and dealing is + * the lobby's job. Hidden rather than disabled: a greyed-out Undo in a multiplayer game invites the + * question "why not?" every turn, and the honest answer is that the control does not belong there. + */ +function applyCapabilities(): void { + const c = session.capabilities; + const hide = (id: string, on: boolean): void => { + const el = document.getElementById(id); + if (el) el.hidden = !on; + }; + hide('undo', c.undo); + hide('savefile', c.saveLocal); + hide('newgame', c.newGame); +} + function render(): void { - const f = view(game); - const menu = actionMenu(game); + const f = session.view(); + const menu = session.menu(); // Which squares the selected card or track piece may go on. Highlighting them is what turns the // coordinate list into a board: you pick the thing, then click where it goes. @@ -166,7 +184,7 @@ function render(): void { const obj = $('objective'); obj.textContent = `${f.revenue} of ${f.objective.target} · ${f.objective.daysLeft} Day${f.objective.daysLeft === 1 ? '' : 's'} left`; obj.className = 'pace'; - $('seed').textContent = String(game.seed); + $('seed').textContent = String(session.seed()); // -- division $('division').innerHTML = divisionSvg(f.division); @@ -255,7 +273,7 @@ function render(): void { function pick(key: string, list: { label: string; index: number }[]): void { if (list.length === 1) { const intent = menu.options[list[0]!.index]; - if (intent) submit(game, intent); + if (intent) void session.submit(intent); selected = null; pendingAt = null; } else { @@ -296,7 +314,7 @@ function render(): void { // The card just drawn, badged so it can be told from the two beside it. It stands until // another draw replaces it, rather than flashing once — the question a player asks looking // at the row is "which of these is new", not "did something happen". - const fresh = h.cardId === game.justDrawn; + const fresh = h.cardId === session.justDrawn(); return ( `
` + @@ -341,7 +359,7 @@ function render(): void { // A card that needs no square goes straight down; there is nothing to ask. if (verb === 'play' && entry.placeKey === null && entry.playNow !== null) { const intent = menu.options[entry.playNow]; - if (intent) submit(game, intent); + if (intent) void session.submit(intent); selected = null; mode = null; pendingAt = null; @@ -374,7 +392,7 @@ function render(): void { node.classList.add('target'); node.onclick = () => { const intent = menu.options[index]; - if (intent) submit(game, intent); + if (intent) void session.submit(intent); selected = null; mode = null; render(); @@ -397,7 +415,7 @@ function render(): void { node.classList.add('addable'); node.onclick = () => { const intent = menu.options[car.index]; - if (intent) submit(game, intent); + if (intent) void session.submit(intent); render(); }; } @@ -409,15 +427,16 @@ function render(): void { * `scheduled` is cleared as it is consumed, so the flash marks the moment rather than the state — * it is gone by the next render, which is what makes it read as "that just happened". */ - const justSet = game.scheduled; - game.scheduled = null; + // Draining: the flash marks the moment the die was read, not a state, so it is gone by the next + // render. Taken once here and handed to both the timetable and the action panel. + const justSet = session.takeScheduled(); $('timetable').innerHTML = timetableHtml(f, justSet); $('blocked').innerHTML = blockedHtml(f); // -- log const log = $('log'); - log.innerHTML = game.log + log.innerHTML = session.lines() .slice(-60) .map((l) => `
${esc(l.text)}
`) .join(''); @@ -446,7 +465,7 @@ function render(): void { // Drain whatever the last batch of events earned. Cleared either way, so turning sound on does // not then play a backlog of everything that happened while it was off. - const cues = game.cues.splice(0, game.cues.length); + const cues = session.takeCues(); if (soundOn) for (const c of cues) playCue(c); save(); @@ -465,27 +484,20 @@ function render(): void { */ function renderUndo(): void { const btn = document.getElementById('undo') as HTMLButtonElement | null; - if (!btn) return; - const n = game.history.length; + if (!btn || !session.capabilities.undo) return; + const n = session.steps(); btn.disabled = n === 0; btn.textContent = n === 0 ? 'Undo' : `Undo (${n})`; btn.onclick = () => { - const back = undo(game); - if (!back) return; - game = back; + // The session drops the rebuilt game's cues and draws — replaying the history re-records them, + // and none of it is news to a player who just stepped back. + if (!session.undo()) return; selected = null; mode = null; pendingAt = null; - // The rebuilt game replays its own cues from the beginning; none of them are news. `justDrawn` - // goes with them: replaying the history re-records every draw, so it would badge whichever card - // the replay happened to end on rather than one the player just turned over. - game.cues.length = 0; - game.scheduled = null; - game.justDrawn = null; // A phase change is announced by comparing against the last frame drawn. Stepping BACK into a // different phase is not that event, so the banner is suppressed rather than fired backwards. lastPhase = null; - render(); }; } @@ -496,7 +508,7 @@ function renderUndo(): void { * Division Yard is BARE. So the interesting number is not how much has been used but how little is * left, and the moment the Division Yard empties a whole pile comes back at once. */ -function renderYards(f: ReturnType): void { +function renderYards(f: Frame): void { $('divyard').innerHTML = yardHtml(f.yards.division); $('clsyard').innerHTML = yardHtml(f.yards.classification); $('divtot').textContent = `${f.yards.divisionTotal} cars`; @@ -510,7 +522,7 @@ function renderYards(f: ReturnType): void { : 'loaded / empty. Classification returns to the Division Yard only when the Division Yard is bare.'; } -function renderDistrict(f: ReturnType): void { +function renderDistrict(f: Frame): void { const open = districtMode === 'auto' ? FOCUS_PHASES.has(f.phaseKey) : districtMode === 'open'; const sec = $('district'); if (open) sec.classList.remove('folded'); @@ -539,18 +551,18 @@ function renderDistrict(f: ReturnType): void { } function renderActions( - menu: ReturnType, - f: ReturnType, + menu: Menu, + f: Frame, justSet: number | null, ): void { const el = $('actions'); - if (game.state.status !== 'active') { - const o = game.state.outcome; + if (f.status !== 'active') { + const o = f.outcome; el.innerHTML = `
` + `${o?.result === 'win' ? 'YOU WIN' : 'GAME OVER'} — ${esc(String(o?.reason ?? ''))}
` + - `final Revenue ${game.state.players[0]?.revenue ?? 0} against a target of ${view(game).objective.target}
` + + `final Revenue ${f.revenue} against a target of ${f.objective.target}
` + ``; $('again').onclick = () => { clearSave(); @@ -566,7 +578,7 @@ function renderActions( const apply = (index: number): void => { const intent = menu.options[index]; - if (intent) submit(game, intent); + if (intent) void session.submit(intent); selected = null; mode = null; pendingAt = null; @@ -582,15 +594,12 @@ function renderActions( * the same card in hand explained itself perfectly — reported on "Realignment on Mainline card 3", * which had neither a name for the card it meant nor a word about what it would do. */ - const actionButton = (label: string, index: number): string => { + const actionButton = (a: { index: number; label: string; tip?: string }): string => { + const { label, index } = a; const cut = label.indexOf(' — '); const head = cut > 0 ? label.slice(0, cut) : label; - let rest = cut > 0 ? label.slice(cut + 3) : ''; - if (!rest) { - const intent = menu.options[index]; - const cardId = intent && 'cardId' in intent ? (intent as { cardId: string }).cardId : null; - if (cardId) rest = cardDescription(game.state, cardId); - } + // The menu resolved the card's description server-side, so the page never needs the state. + const rest = cut > 0 ? label.slice(cut + 3) : (a.tip ?? ''); return ( `` ); @@ -644,7 +653,7 @@ function renderActions( // §6.2 — a drawn card has to be played or discarded before the turn can end. Keyed on // the INTENT, not the label: matching button text would break the moment the wording // changed, and would have caught `switch.end` too. - return actionButton(a.label, a.index); + return actionButton(a); }) .join('') + ``, @@ -718,12 +727,12 @@ function renderActions( html += ``; } if ( - game.state.clock.phase === 'localOps' && - game.state.turn.option === 'draw' && + f.phaseKey === 'localOps' && + f.option === 'draw' && !menu.options.some((i) => i.type === 'draw.end') ) { // The hand being counted is the ACTOR's — they are the one who cannot end the turn. - const hand = (game.state.decks.hands.get(currentActor(game) ?? 0) ?? []).length; + const hand = f.handCount; html += `
`; @@ -749,28 +758,29 @@ function renderActions( * where a rendered page would have been megabytes. */ function downloadSave(): void { - const data = JSON.stringify(toSave(game), null, 1); + const data = JSON.stringify(session.save(), null, 1); const blob = new Blob([data], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = `station-master-seed${game.seed}-day${game.state.clock.day}.json`; + a.download = `station-master-seed${session.seed()}-day${session.view().day}.json`; a.click(); URL.revokeObjectURL(url); } function save(): void { + if (!session.capabilities.saveLocal) return; try { - localStorage.setItem(SAVE_KEY, JSON.stringify(toSave(game))); + localStorage.setItem(SAVE_KEY, JSON.stringify(session.save())); } catch { // A full or disabled localStorage must not take the game down with it. } } -function load(): ReturnType | null { +function load(): Save | null { try { const raw = localStorage.getItem(SAVE_KEY); - return raw ? (JSON.parse(raw) as ReturnType) : null; + return raw ? (JSON.parse(raw) as Save) : null; } catch { return null; } @@ -810,9 +820,10 @@ if (saveBtn) saveBtn.onclick = downloadSave; const newBtn = document.getElementById('newgame'); if (newBtn) { newBtn.onclick = () => { - const day = game.state.clock.day; - const started = game.state.status === 'active' && (day > 1 || game.state.clock.stage > 1); - if (started && !confirm(`Forget this game (seed ${game.seed}, Day ${day}) and deal a new one?`)) return; + const f = session.view(); + const day = f.day; + const started = f.status === 'active' && (day > 1 || f.stage > 1); + if (started && !confirm(`Forget this game (seed ${session.seed()}, Day ${day}) and deal a new one?`)) return; /** * ASK FOR THE SEED, rather than documenting a URL parameter in the title bar. * diff --git a/src/web/session.ts b/src/web/session.ts new file mode 100644 index 0000000..1a93c1c --- /dev/null +++ b/src/web/session.ts @@ -0,0 +1,172 @@ +/** + * The boundary between the page and the game. + * + * The page draws a `Frame` and offers a `Menu`, and submits intents. It does not care whether the + * rules are being applied a function call away or across a network — which is the whole point: + * + * - `LocalSession` runs the engine in this browser. Solitaire, exactly as it has always worked, + * with no server involved at any point. + * - `RemoteSession` (not built yet — see `docs/architecture/multiplayer.md` Phase 2) will hold no + * authoritative state at all. It cannot: it has neither the deck order nor the other players' + * hands, and if it did the game would be cheatable. + * + * So this interface is deliberately the SMALLER of the two — everything a remote client could + * possibly offer, and nothing that only a local one can do. What a local session can do beyond it is + * declared in `capabilities`, and the page hides those controls rather than calling them and failing. + */ + +import type { Intent } from '../engine/intents.ts'; +import type { Frame } from '../sim/view.ts'; +import type { GameConfig, PlayerIndex } from '../engine/state.ts'; +import type { Game, Menu, Save } from './game.ts'; +import { + actionMenu, + currentActor, + fromSave, + handPlayable, + newGame, + overHandLimit, + submit, + toSave, + undo, + view, +} from './game.ts'; + +/** + * What this session can do beyond the common interface. + * + * None of these survive a server. Undo would have to un-see what other players have already seen; + * a local save is meaningless when the server is the store; and dealing a new game is the lobby's + * job. The page reads these rather than assuming, so the same code drives both. + */ +export type Capabilities = { + undo: boolean; + saveLocal: boolean; + newGame: boolean; +}; + +export type Session = { + /** The board as this seat sees it. */ + view(): Frame; + /** What this seat may do right now. */ + menu(): Menu; + /** Which seat this client is playing. */ + seat(): PlayerIndex; + /** Whose turn it is, or null when the game is over or waiting on nothing. */ + actor(): PlayerIndex | null; + /** True when the hand is over §6.2's limit and the turn cannot be ended. */ + overHandLimit(): boolean; + /** Which cards in hand are playable right now, in hand order. */ + handPlayable(): boolean[]; + /** + * Propose an action. Resolves false if the rules refused it. + * + * Async because a remote session must be, even though the local one answers immediately — a page + * written against a synchronous `submit` would have to be rewritten for the server. + */ + submit(intent: Intent): Promise; + /** Called whenever something changed and the page should redraw. Returns an unsubscribe. */ + subscribe(fn: () => void): () => void; + capabilities: Capabilities; + + /** + * WHAT JUST HAPPENED — three transient signals the page uses to draw a moment rather than a state. + * + * They are separate from `view()` because two of them are CONSUMED: a Stage flash and a sound play + * once and are then gone, whereas the Frame can be rebuilt any number of times per render. Putting + * them on the Frame would mean re-flashing on every redraw. + * + * A remote session fills these from the server's pushes rather than from a local event log; the + * page cannot tell the difference. Whether they eventually ride on the Frame as animation hints is + * a Phase 2 question (`docs/architecture/multiplayer.md` D3). + */ + /** The narrated history, newest last. */ + lines(): { text: string; tone: string }[]; + /** Sounds earned since the last call. Draining. */ + takeCues(): string[]; + /** The timetable slot the last 1D12 filled, once. Draining. */ + takeScheduled(): number | null; + /** The card most recently drawn into this seat's hand. Persists until another draw replaces it. */ + justDrawn(): string | null; +}; + +/** + * Everything a LOCAL session can additionally do. Kept off `Session` so that reaching for one of + * these in shared page code is a type error rather than a runtime surprise against a server. + */ +export type LocalSession = Session & { + readonly game: Game; + seed(): number; + save(): Save; + /** How many intents have been submitted — what the Undo button counts down. */ + steps(): number; + /** Steps back one intent by replaying the history without it. Returns false at the start. */ + undo(): boolean; + restore(save: Save): void; +}; + +/** + * A session that owns the engine in this process. + * + * `Game` is mutated in place by `submit`, so the wrapper keeps a mutable reference rather than + * copying — `undo` and `restore` replace the whole game, which is why `game` is a getter. + */ +export function createLocalSession(seed: number, config?: GameConfig): LocalSession { + let game: Game = config ? newGame(seed, config) : newGame(seed); + const listeners = new Set<() => void>(); + const changed = (): void => { + for (const fn of [...listeners]) fn(); + }; + + return { + get game() { + return game; + }, + view: () => view(game), + menu: () => actionMenu(game), + seat: () => 0, + actor: () => currentActor(game), + overHandLimit: () => overHandLimit(game), + handPlayable: () => handPlayable(game), + submit: async (intent: Intent) => { + const ok = submit(game, intent); + if (ok) changed(); + return ok; + }, + subscribe(fn: () => void) { + listeners.add(fn); + return () => listeners.delete(fn); + }, + capabilities: { undo: true, saveLocal: true, newGame: true }, + + lines: () => game.log, + takeCues: () => game.cues.splice(0, game.cues.length), + takeScheduled: () => { + const slot = game.scheduled; + game.scheduled = null; + return slot; + }, + justDrawn: () => game.justDrawn, + + seed: () => game.seed, + save: () => toSave(game), + steps: () => game.history.length, + undo() { + const back = undo(game); + if (!back) return false; + game = back; + // The rebuilt game replays its own history, so every cue and draw in it is old news. + back.cues.length = 0; + back.scheduled = null; + back.justDrawn = null; + changed(); + return true; + }, + restore(save: Save) { + game = fromSave(save); + // Restoring replays the whole history and re-records every draw; none of it is news. + game.justDrawn = null; + changed(); + }, + }; +} diff --git a/test/advance.test.ts b/test/advance.test.ts index fb07579..d0d3892 100644 --- a/test/advance.test.ts +++ b/test/advance.test.ts @@ -586,7 +586,7 @@ describe('X18 Circus Train — a point for standing still', () => { s.trays.set('circus', { id: 'circus', trainNumber: 18, trainIsExtra: true, engineAt: 0, consist: [], direction: 'east', - position: { at: 'grid', owner: 0, coord: { row: -1, col: 0 } }, + position: { at: 'grid', seat: 0, coord: { row: -1, col: 0 } }, movesUsed: 0, } as never); // A card under it, so the crew is somewhere real rather than off the grid. diff --git a/test/apply.test.ts b/test/apply.test.ts index dbf851d..ef52b36 100644 --- a/test/apply.test.ts +++ b/test/apply.test.ts @@ -12,7 +12,7 @@ import type { Intent } from '../src/engine/intents.ts'; import { legalActions } from '../src/engine/legal.ts'; import { createGame } from '../src/engine/setup.ts'; import type { CrewTray, GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts'; -import { coordKey } from '../src/engine/state.ts'; +import { coordKey, turnOf } from '../src/engine/state.ts'; import { cardDescription, snapshot } from '../src/sim/view.ts'; const config: GameConfig = { @@ -42,7 +42,7 @@ function placeTray(s: GameState, coord: GridCoord, consist: CrewTray['consist'] engineAt: 0, consist, direction: 'east', - position: { at: 'grid', owner: 0, coord }, + position: { at: 'grid', seat: 0, coord }, movesUsed: 0, }); return id; @@ -90,7 +90,7 @@ describe('Local Operations: the three-way exclusive choice (§6)', () => { const s = game(); const r = applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); assert.ok(r.ok); - assert.equal(s.turn.option, 'draw'); + assert.equal(turnOf(s, 0).option, 'draw'); }); it('forecloses the other two options for the Stage', () => { @@ -366,7 +366,7 @@ describe('Local Operations: switching (§6.1, Appendix A)', () => { applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' }); const r = applyIntent(s, 0, { type: 'switch.move', trayId: tray, to: at(0, 2), reverse: false }); assert.ok(r.ok); - assert.equal(s.turn.movesRemaining, MOVES_PER_LOCAL_OPS - 1); + assert.equal(turnOf(s, 0).movesRemaining, MOVES_PER_LOCAL_OPS - 1); }); it('couples standing cars automatically and mandatorily', () => { @@ -395,7 +395,7 @@ describe('Local Operations: switching (§6.1, Appendix A)', () => { const s = game(); const tray = placeTray(s, at(0, 1)); applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' }); - s.turn.movesRemaining = 0; + turnOf(s, 0).movesRemaining = 0; assert.equal( check(s, 0, { type: 'switch.move', trayId: tray, to: at(0, 0), reverse: false }), 'NO_MOVES_REMAINING', @@ -993,7 +993,7 @@ describe('Industry cards go on a stub, and lock each other out', () => { })); s.clock.phase = 'localOps'; s.clock.currentActor = 0; - s.turn.option = 'draw'; + turnOf(s, 0).option = 'draw'; return at(area.runningRow - 1, col + 1); } @@ -1151,7 +1151,7 @@ describe('the Crew Tray is a train, and must be made up to leave (§8.2, Appendi s.trays.get(id)!.engineAt = 1; s.clock.phase = 'localOps'; s.clock.currentActor = 0; - s.turn.option = 'switch'; + turnOf(s, 0).option = 'switch'; // One car ahead of the engine and one behind: at most one may come off either end. assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: id, count: 1, fromNose: true }), null); assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: id, count: 1 }), null); @@ -1185,7 +1185,7 @@ describe('only the train being made up may take cars (§7)', () => { // Point. `check` did not, so the Division Yard would hand cars to a train sitting on a siding // in your own district: cars appeared on a train nobody was making up. const s = game(); - trayAt(s, { at: 'grid', owner: 0, coord: at(0, 1) }); + trayAt(s, { at: 'grid', seat: 0, coord: at(0, 1) }); assert.equal(check(s, 0, addHopper), 'NOT_BEING_MADE_UP'); }); @@ -1225,7 +1225,7 @@ describe('an Office upgrade keeps what Modifiers added (§9)', () => { for (const [id, card] of s.cards) { if (card.kind.kind !== 'office' || card.kind.tier !== tier) continue; s.decks.hands.set(0, [id]); - s.turn.option = null; + turnOf(s, 0).option = null; applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); const r = applyIntent(s, 0, { type: 'card.play', cardId: id }); assert.ok(r.ok, `${tier} upgrade should apply`); diff --git a/test/enhancements.test.ts b/test/enhancements.test.ts index 88832df..15c70f3 100644 --- a/test/enhancements.test.ts +++ b/test/enhancements.test.ts @@ -14,7 +14,7 @@ import { applyIntent, areaOf, check, hasDistrictEnhancement, isProtectedFromDera import { ENHANCEMENT_RULES, enhancementRule, trainProfile } from '../src/engine/content.ts'; import { createGame } from '../src/engine/setup.ts'; import type { GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts'; -import { coordKey, subdivisions } from '../src/engine/state.ts'; +import { coordKey, subdivisions, turnOf } from '../src/engine/state.ts'; const config: GameConfig = { mode: 'solitaire', @@ -65,7 +65,7 @@ function placeTray(s: GameState, coord: GridCoord, consist: TrackCard['standing' const id = s.freeTrays.pop()!; s.trays.set(id, { id, trainNumber: 9, trainIsExtra: false, engineAt: 0, - consist, direction: 'east', position: { at: 'grid', owner: 0, coord }, movesUsed: 0, + consist, direction: 'east', position: { at: 'grid', seat: 0, coord }, movesUsed: 0, }); return id; } @@ -197,9 +197,9 @@ describe('Small Yard — the card that makes switching solvable', () => { it('costs one Move — "spends one move in the yard"', () => { const { s, tray } = yardGame(); - const before = s.turn.movesRemaining; + const before = turnOf(s, 0).movesRemaining; applyIntent(s, 0, { type: 'switch.sortConsist', trayId: tray, order: [2, 1, 0] }); - assert.equal(s.turn.movesRemaining, before - 1); + assert.equal(turnOf(s, 0).movesRemaining, before - 1); }); it('is refused anywhere without a Small Yard', () => { @@ -338,7 +338,7 @@ describe('one Revenue for every train that clears your section', () => { const id = 'leaving'; s.trays.set(id, { id, trainNumber: 2, trainIsExtra: false, engineAt: 0, - consist: [], direction, position: { at: 'grid', owner: 0, coord: areaOf(s, 0).officeCoord }, + consist: [], direction, position: { at: 'grid', seat: 0, coord: areaOf(s, 0).officeCoord }, movesUsed: 0, }); areaOf(s, 0).adOccupancy.push(id); diff --git a/test/mainline-cards.test.ts b/test/mainline-cards.test.ts index 08c6d89..544d8f9 100644 --- a/test/mainline-cards.test.ts +++ b/test/mainline-cards.test.ts @@ -27,7 +27,7 @@ import { import { createGame } from '../src/engine/setup.ts'; import type { GameEvent } from '../src/engine/events.ts'; import type { GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts'; -import { coordKey } from '../src/engine/state.ts'; +import { coordKey, turnOf } from '../src/engine/state.ts'; import { snapshot } from '../src/sim/view.ts'; const config: GameConfig = { @@ -81,8 +81,8 @@ function pinned(s: GameState, index: number, card: string) { function drawTurn(s: GameState): void { s.clock.phase = 'localOps'; s.clock.currentActor = 0; - s.turn.option = 'draw'; - s.turn.drawnThisTurn = true; + turnOf(s, 0).option = 'draw'; + turnOf(s, 0).drawnThisTurn = true; } // --------------------------------------------------------------------------- @@ -343,12 +343,12 @@ describe('Flying Switch rolls a cut into an industry', () => { id: 'crew', trainNumber: 1, trainIsExtra: false, engineAt: 0, consist: [{ type: 'hopper', loaded: false }, { type: 'boxcar', loaded: false }], direction: 'east', facing: 'e', - position: { at: 'grid', owner: 0, coord: at(-1, 2) }, movesUsed: 0, + position: { at: 'grid', seat: 0, coord: at(-1, 2) }, movesUsed: 0, }); s.clock.phase = 'localOps'; s.clock.currentActor = 0; - s.turn.option = 'switch'; - s.turn.movesRemaining = 6; + turnOf(s, 0).option = 'switch'; + turnOf(s, 0).movesRemaining = 6; return industry; } @@ -367,10 +367,10 @@ describe('Flying Switch rolls a cut into an industry', () => { assert.equal(s.trays.get('crew')!.consist.length, 1, 'the cut leaves the consist'); assert.deepEqual( s.trays.get('crew')!.position, - { at: 'grid', owner: 0, coord: at(-1, 2) }, + { at: 'grid', seat: 0, coord: at(-1, 2) }, 'the engine never enters the industry', ); - assert.equal(s.turn.movesRemaining, 5, 'the manoeuvre costs a Move'); + assert.equal(turnOf(s, 0).movesRemaining, 5, 'the manoeuvre costs a Move'); }); it('only reaches an adjacent card', () => { @@ -454,7 +454,7 @@ describe('the Limits sign moves with the Running Track (§2.1, Gap 4a)', () => { // — track on the far side of the sign, and a second sign planted beyond that. const s = game(); const area = areaOf(s, 0); - s.turn.option = 'draw'; + turnOf(s, 0).option = 'draw'; const straight = trackInHand(s, 'straight', 'none'); const beyondEast = { row: area.runningRow, col: area.limitsEast.col + 1 }; @@ -508,7 +508,7 @@ describe('the Limits sign moves with the Running Track (§2.1, Gap 4a)', () => { // player's Limits is what makes a collision his fault. const s = game(); const area = areaOf(s, 0); - s.turn.option = 'draw'; + turnOf(s, 0).option = 'draw'; for (let n = 0; n < 4; n++) { const target = { row: area.runningRow, col: area.limitsWest.col }; @@ -549,7 +549,7 @@ describe('a turnout may be laid on top of a card already down', () => { // straight along the main and then wanted to branch there had no move at all — the piece had to // have been a turnout when it went down. const s = game(); - s.turn.option = 'draw'; + turnOf(s, 0).option = 'draw'; const at = layStraight(s); for (const hand of ['left', 'right'] as const) { @@ -575,7 +575,7 @@ describe('a turnout may be laid on top of a card already down', () => { // opposite edges, so swapping one for the other would move the leg off whatever it joined. const s = game(); const area = areaOf(s, 0); - s.turn.option = 'draw'; + turnOf(s, 0).option = 'draw'; // A turnout on the main, then its matching curve on the row below — the start of every siding. const turnoutAt = { row: area.runningRow, col: area.limitsEast.col }; @@ -609,7 +609,7 @@ describe('a turnout may be laid on top of a card already down', () => { it('refuses to swap the track out from under a car, or out from under an Interlocking', () => { const s = game(); const area = areaOf(s, 0); - s.turn.option = 'draw'; + turnOf(s, 0).option = 'draw'; const at = layStraight(s); const square = area.grid.get(`${at.row},${at.col}`)!; @@ -633,7 +633,7 @@ describe('a turnout may be laid on top of a card already down', () => { // The Office is east-west track like a straight, but it is not track to build over. const s = game(); const area = areaOf(s, 0); - s.turn.option = 'draw'; + turnOf(s, 0).option = 'draw'; assert.equal( check(s, 0, { type: 'card.play', cardId: trackInHand(s, 'turnout', 'right'), @@ -646,7 +646,7 @@ describe('a turnout may be laid on top of a card already down', () => { it('does not let a straight or a curve upgrade anything — only a turnout may', () => { const s = game(); - s.turn.option = 'draw'; + turnOf(s, 0).option = 'draw'; const at = layStraight(s); assert.equal( @@ -665,7 +665,7 @@ describe('a turnout may be laid on top of a card already down', () => { // The 18 Enhancement cards were permanently dead for weeks for exactly this reason: the engine // allowed the play and `legalIntents` never once offered a square it could go on. const s = game(); - s.turn.option = 'draw'; + turnOf(s, 0).option = 'draw'; const at = layStraight(s); const cardId = trackInHand(s, 'turnout', 'right'); diff --git a/test/multiplayer.test.ts b/test/multiplayer.test.ts index 07d66aa..8c5a21f 100644 --- a/test/multiplayer.test.ts +++ b/test/multiplayer.test.ts @@ -15,9 +15,12 @@ import { 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'; -import { coordKey, subdivisions } from '../src/engine/state.ts'; +import { coordKey, playerAtSeat, seatOf, subdivisions } from '../src/engine/state.ts'; import { developerBot, playGame } from '../src/sim/bot.ts'; import { snapshot } from '../src/sim/view.ts'; +import { impediments } from '../src/sim/narrate.ts'; +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; const competitive: GameConfig = { mode: 'competitive', @@ -39,11 +42,34 @@ 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', owner, coord: area.officeCoord }, movesUsed: 0, + direction, position: { at: 'grid', seat: owner, coord: area.officeCoord }, movesUsed: 0, }); area.adOccupancy.push(id); } +/** An industry with nothing in its green box — the simplest thing `impediments` reports. */ +const idleIndustry = () => + ({ + geometry: { kind: 'facility', facility: 'mineTipple' }, + baseOperationalRail: true, + standing: [], + modifiers: [], + enhancements: [], + facility: { + kind: 'freight', + subtype: 'mineTipple', + allows: { outbound: true, inbound: false }, + outboundBox: [], + inboundBox: [], + capacity: { outbound: 1, inbound: 0 }, + menAtWork: [null, null, null], + industryTrack: { length: 1, cars: [] }, + laborers: 1, + porters: 0, + usedThisStage: { laborers: 0, porters: 0 }, + }, + }) as never; + const pinTerrain = (s: GameState): void => { // Double Track and Uncontrolled Siding print "trains may pass", which would clear any departure. for (const n of s.division.nodes) if (n.kind === 'mainline') n.card = 'plains'; @@ -68,7 +94,7 @@ describe('multi-player games run at all', () => { 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).owner, p, 'an Office Area is owned by the wrong seat'); + assert.equal(areaOf(s, p).seat, p, 'an Office Area is owned by the wrong seat'); } // §7 — trays are scarce on purpose, and the count is per player count. assert.equal(s.freeTrays.length, crewTrayCount(players), `${players}p crew trays`); @@ -163,7 +189,7 @@ describe('Subdivisions are split by whoever is a Control Point', () => { // 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.owner === owner); + s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === owner); 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]) { @@ -241,6 +267,125 @@ 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`); + 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); + } + } + }); + + it("follows a player to their new Office when the seating rotates", () => { + /** + * Employee Rotation (Appendix B) moves every player one seat left at the end of a Day while + * their Revenue and the Fedora travel with them. The rule is not implemented, but the MODEL now + * supports it: rotating `seating` is the whole operation, and every `areaOf(s, player)` caller + * follows without changing. This is the test that the split actually bought something. + */ + const s = game(3); + const officeOf = (p: PlayerIndex): number => areaOf(s, p).seat; + assert.deepEqual([0, 1, 2].map(officeOf), [0, 1, 2]); + + // 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]!; + const tierOf = (p: PlayerIndex): string => areaOf(s, p).tier; + assert.deepEqual([0, 1, 2].map(tierOf), ['depot', 'station', 'terminal']); + + // Everyone shuffles one chair along. The Offices do not move; the people do. + s.seating = [2, 0, 1]; + + 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'); + // 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'); + }); + + it('plays a whole game with the seating rotated', () => { + /** + * The abstraction is only worth something if the game still RUNS through it. Rotating `seating` + * before the first move puts every seat/player confusion in the engine on the critical path at + * once — the terminal check, the dispatch devices, the bot's own district lookups — where under + * the identity mapping they were all silently correct. + */ + const s = game(3); + s.seating = [2, 0, 1]; + const r = playGame(s, developerBot, pump); + assert.ok(r.finished, 'a rotated game did not finish'); + assert.equal(new Set(s.players.map((p) => p.index)).size, 3, 'seats stopped being distinct'); + // Each Office is still occupied by exactly one player, and by the one `seating` says. + for (let seat = 0; seat < 3; seat++) { + assert.equal(areaOf(s, playerAtSeat(s, seat)).seat, seat, `seat ${seat} lost its occupant`); + } + }); + + it('reports the impediments of the district a player is now sitting at', () => { + /** + * `impediments` took a PLAYER and used it as an `officeAreas` key, and `Frame.blocked` is built + * from it — so after a rotation a player would have been shown the jams of whoever inherited + * their old chair, naming squares that are not on the board in front of them. + */ + const s = game(3); + const area = areaOf(s, 1); + area.grid.set(coordKey({ row: area.runningRow - 1, col: 0 }), idleIndustry()); + + 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'); + + // 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'); + assert.deepEqual( + [0, 1, 2].map((p) => snapshot(s, [], null, null, null, false, p).blocked.length > 0), + [true, false, false], + 'the Frame disagrees with impediments after a rotation', + ); + }); +}); + +describe('nothing keys an Office Area by player', () => { + it('reaches officeAreas only through areaAtSeat', () => { + /** + * The class of bug, rather than the five instances of it that were found by hand. `officeAreas` + * is keyed by SEAT; every one of these lookups was passing a PLAYER, which is right only while + * seating is the identity mapping and wrong the moment Employee Rotation lands. Routing them all + * through `areaOf`/`areaAtSeat` fixes them; this keeps them fixed. + */ + // `state.ts` and `apply.ts` hold the seat-typed accessors — `areaAtSeat`, `isControlPoint`, + // `adTrackCount` — and are the only files allowed to touch the map directly. Everywhere else + // goes through them, which is what makes the argument's meaning checkable at the call site. + const allowed = ['engine/state.ts', 'engine/apply.ts']; + const root = join(import.meta.dirname, '..', 'src'); + const offenders: string[] = []; + const walk = (dir: string): void => { + for (const e of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, e.name); + if (e.isDirectory()) walk(full); + else if (e.name.endsWith('.ts')) { + readFileSync(full, 'utf8').split('\n').forEach((line, i) => { + // `areaAtSeat` is the one legal reader — it is what everything else goes through. + if (line.includes('officeAreas.get(') && !allowed.some((a) => full.endsWith(a))) { + offenders.push(`${full}:${i + 1}`); + } + }); + } + } + }; + walk(root); + assert.deepEqual(offenders, [], 'officeAreas is being indexed outside areaAtSeat'); + }); +}); + describe('the view shows one seat at a time', () => { it('gives each seat its own board and its own hand', () => { /** @@ -276,6 +421,72 @@ describe('the view shows one seat at a time', () => { assert.deepEqual(frames.map((f) => f.hand.length), [1, 2, 3], 'seats do not have distinct hands'); }); + it('gives each seat its own Revenue, pace note, Moves and impediments', () => { + /** + * The board and the hand were seat-scoped; four scalar fields beside them were not, and each was + * still reporting player 0. A player looking at their own railroad would have been shown someone + * else's score, someone else's Moves left, and someone else's jammed facilities — the last of + * which is a list of squares that do not exist on the board they are looking at. + */ + const s = game(3); + s.players[0]!.revenue = 1; + s.players[1]!.revenue = 9; + s.players[2]!.revenue = 17; + + const frames = [0, 1, 2].map((p) => snapshot(s, [], null, null, null, false, p)); + assert.deepEqual(frames.map((f) => f.revenue), [1, 9, 17], 'seats do not see their own Revenue'); + + // The pace note is computed from the viewer's score against the clock, so it must move with it. + const pace = frames.map((f) => f.objective.onPace); + assert.notDeepEqual(pace, [pace[0], pace[0], pace[0]], 'every seat got the same pace verdict'); + assert.ok(frames[2]!.objective.note.startsWith('17 of '), `seat 2's note reads "${frames[2]!.objective.note}"`); + + // Stand an unstocked industry in ONE district — "green box empty" is the impediment. Only that + // seat should be told about it. + const area = areaOf(s, 1); + const key = coordKey({ row: area.runningRow - 1, col: 0 }); + area.grid.set(key, { + geometry: { kind: 'facility', facility: 'mineTipple' }, + baseOperationalRail: true, + standing: [], + modifiers: [], + enhancements: [], + facility: { + kind: 'freight', + subtype: 'mineTipple', + allows: { outbound: true, inbound: false }, + outboundBox: [], + inboundBox: [], + capacity: { outbound: 1, inbound: 0 }, + menAtWork: [null, null, null], + industryTrack: { length: 1, cars: [] }, + laborers: 1, + porters: 0, + usedThisStage: { laborers: 0, porters: 0 }, + }, + } as never); + + const blocked = [0, 1, 2].map((p) => snapshot(s, [], null, null, null, false, p).blocked.length); + assert.deepEqual([blocked[0], blocked[2]], [0, 0], 'an impediment in one district was reported to the others'); + assert.ok(blocked[1]! > 0, 'the district with the idle industry was not told about it'); + }); + + it('never draws one seat’s crew onto another seat’s board', () => { + /** + * REGRESSION. The tray lookup was keyed by `row,col` alone while every district uses the same + * origin, so a crew standing at (0, 1) in one Office Area appeared at (0, 1) in ALL of them. The + * cells were the viewer's own; the train drawn on them was whoever's happened to be there. + */ + const s = game(3); + // Crews are made up during play, so the opening state has none — stand one in seat 1's district. + readyToLeave(s, 1, 'crew-1', 'east'); + + const withTray = (p: PlayerIndex): number => + snapshot(s, [], null, null, null, false, p).cells.filter((c) => c.tray != null).length; + assert.equal(withTray(1), 1, "the crew is not on its own seat's board"); + assert.deepEqual([withTray(0), withTray(2)], [0, 0], "a crew was drawn onto another seat's board"); + }); + it('defaults to seat 0, so solitaire and every replay are unaffected', () => { const s = game(2); assert.deepEqual( diff --git a/test/session.test.ts b/test/session.test.ts new file mode 100644 index 0000000..f812437 --- /dev/null +++ b/test/session.test.ts @@ -0,0 +1,232 @@ +/** + * The Session boundary. + * + * Phase 1 of `docs/architecture/multiplayer.md` moved the page off `game.ts` and onto a `Session`, + * so that a server can later be substituted for the local engine without the page noticing. The + * whole point of the change is that nothing about solitaire changed, which is a hard thing to prove + * by playing — hence this file: the same seed driven the same way through both routes must land in + * the same position, card for card. + * + * The rest of the suite covers what a remote session will have to reproduce exactly: which calls + * fire the redraw, which signals drain, and what `capabilities` admits a local session can do that a + * server cannot. + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { actionGroups, currentActor, handPlayable, newGame, overHandLimit, submit, toSave, view } from '../src/web/game.ts'; +import { createLocalSession } from '../src/web/session.ts'; + +/** + * Drive a session by always taking the first offered action. + * + * `menu().options` and `actionGroups(game).options` are the same `legalActions` list in the same + * order, so taking index 0 on either side is the same rule — which is what makes the two routes + * comparable below. + */ +async function playSession(seed: number, maxTurns = 20_000) { + const session = createLocalSession(seed); + let turns = 0; + for (; turns < maxTurns; turns++) { + if (session.actor() === null) break; + const options = session.menu().options; + if (options.length === 0) break; + if (!(await session.submit(options[0]!))) break; + } + return { session, turns }; +} + +/** One action through the session, first option, for tests that just need the game to move. */ +async function step(session: ReturnType): Promise { + const options = session.menu().options; + if (options.length === 0) return false; + return session.submit(options[0]!); +} + +describe('a local session plays the same game as the calls it replaced', () => { + it('reaches an identical position from the same seed', async () => { + // The equivalence proof. `game.ts` directly on the left, the Session on the right, same seed and + // same tie-breaking rule — if the boundary leaked anything the boards diverge. + const game = newGame(77); + for (let i = 0; i < 20_000; i++) { + if (currentActor(game) === null) break; + const { options } = actionGroups(game); + if (options.length === 0) break; + if (!submit(game, options[0]!)) break; + } + + const { session, turns } = await playSession(77); + assert.ok(turns > 50, `only ${turns} decisions — the game stalled`); + + const f = session.view(); + assert.equal(f.status, 'finished'); + assert.equal(f.status, view(game).status); + assert.equal(f.day, view(game).day); + assert.deepEqual(f.cells, view(game).cells, 'the board differs across the boundary'); + assert.deepEqual(session.save(), toSave(game), 'the histories differ across the boundary'); + }); + + it('reports the same seat, actor and hand as the underlying game', () => { + const session = createLocalSession(31); + assert.equal(session.seat(), 0); + assert.equal(session.actor(), currentActor(session.game)); + assert.deepEqual(session.handPlayable(), handPlayable(session.game)); + assert.equal(session.overHandLimit(), overHandLimit(session.game)); + }); +}); + +describe('a session tells the page when to redraw', () => { + it('notifies on an accepted intent and not on a refused one', async () => { + const session = createLocalSession(404); + let redraws = 0; + session.subscribe(() => { + redraws++; + }); + + assert.ok(await step(session)); + assert.equal(redraws, 1); + + // A refused intent leaves the game where it was, so there is nothing to redraw. This matters + // more than it looks: a remote session will push on state change, and a page that redrew on + // every submit would flicker on every rejection the server sends back. + assert.equal(await session.submit({ type: 'turn.end', player: 0 } as never), false); + assert.equal(redraws, 1); + }); + + it('stops notifying after unsubscribe', async () => { + const session = createLocalSession(404); + let redraws = 0; + const off = session.subscribe(() => { + redraws++; + }); + assert.ok(await step(session)); + off(); + await step(session); + assert.equal(redraws, 1); + }); +}); + +describe('the transient signals drain', () => { + it('hands out cues once', async () => { + // Cues are a moment, not a state — the Frame can be rebuilt any number of times per render, and + // a sound that replayed on each rebuild would stutter. So they are taken, not read. + const session = createLocalSession(88); + for (let i = 0; i < 40; i++) { + if (session.actor() === null) break; + if (!(await step(session))) break; + if (session.takeCues().length > 0) { + assert.deepEqual(session.takeCues(), [], 'a cue was handed out twice'); + return; + } + } + assert.fail('no cue was earned in 40 actions — the driver never reached a sounding event'); + }); + + it('hands out a scheduled slot once', () => { + const session = createLocalSession(88); + session.game.scheduled = 3; + assert.equal(session.takeScheduled(), 3); + assert.equal(session.takeScheduled(), null, 'the timetable flash fired twice'); + }); + + it('keeps the newest card badged until another draw replaces it', () => { + // Unlike the other two this one PERSISTS: it says which card is new, not that something just + // happened, so it survives redraws and is superseded rather than consumed. + const session = createLocalSession(88); + session.game.justDrawn = 'card-a'; + assert.equal(session.justDrawn(), 'card-a'); + assert.equal(session.justDrawn(), 'card-a'); + }); +}); + +describe('undo and restore rebuild the game without leaking the replay', () => { + it('steps back one action and refuses at the start', async () => { + const session = createLocalSession(909); + assert.equal(session.steps(), 0); + assert.equal(session.undo(), false, 'undo at the start must be a no-op'); + + // Cloned: `toSave` hands back the game's own history array, so holding the object would watch it + // grow rather than record where the game was. + const before = structuredClone(session.save()); + await step(session); + assert.equal(session.steps(), 1); + + assert.equal(session.undo(), true); + assert.equal(session.steps(), 0); + assert.deepEqual(session.save(), before, 'undo did not return to the previous position'); + }); + + it('drops the rebuilt game’s cues and draws', async () => { + // Undo replays the history from the start, which re-earns every cue and re-records every draw + // along the way. None of that is news to a player who just stepped back, so a page that read it + // would replay a whole game of sounds and badge whichever card the replay ended on. + const session = createLocalSession(909); + for (let i = 0; i < 12; i++) { + if (session.actor() === null) break; + if (!(await step(session))) break; + } + session.takeCues(); + + assert.equal(session.undo(), true); + assert.deepEqual(session.takeCues(), [], 'undo replayed the game’s sounds'); + assert.equal(session.takeScheduled(), null); + assert.equal(session.justDrawn(), null, 'undo badged a card from the replay'); + }); + + it('restores to the same position with nothing badged', async () => { + const session = createLocalSession(909); + for (let i = 0; i < 30; i++) { + if (session.actor() === null) break; + if (!(await step(session))) break; + } + const save = session.save(); + const cells = session.view().cells; + + const fresh = createLocalSession(1); + fresh.restore(save); + assert.equal(fresh.seed(), save.seed, 'restore kept the session’s original seed'); + assert.equal(fresh.steps(), save.history.length); + assert.deepEqual(fresh.view().cells, cells, 'the board differs after restore'); + assert.equal(fresh.justDrawn(), null, 'restore badged a card from the replay'); + }); +}); + +describe('the page stays on the near side of the boundary', () => { + const main = readFileSync(join(import.meta.dirname, '..', 'src', 'web', 'main.ts'), 'utf8'); + + it('never reaches through to GameState', () => { + // There were eleven of these, and each one was a place the page knew something a server would + // never send it — the deck order, another player's hand, the RNG. They all had to go before a + // `RemoteSession` could be dropped in, and the cheapest way to keep them gone is to say so here: + // a reader that comes back is a failing test rather than a bug found in Phase 2. + const reaches = main.match(/\b(session\.game|game)\.state\b/g) ?? []; + assert.deepEqual(reaches, [], 'main.ts reached through the Session into GameState'); + }); + + it('imports only types from game.ts', () => { + // Everything the page DOES now goes through `session`. Values from `game.ts` — `submit`, `view`, + // `undo` — are the local engine by another name, and importing one is how the boundary gets + // quietly reopened. Types are fine: `Frame` and `Menu` are what a server sends. + const lines = main.split('\n').filter((l) => l.includes("from './game.ts'")); + assert.ok(lines.length > 0, 'the check found no game.ts import at all — it has stopped checking'); + for (const l of lines) { + assert.match(l, /^import type /, `main.ts imports values from game.ts: ${l.trim()}`); + } + }); +}); + +describe('capabilities say what only a local session can do', () => { + it('offers undo, a local save and a new deal', () => { + // The page hides these rather than calling them and failing. A server can offer none of them: it + // cannot un-see what other players have already seen, it is itself the store, and dealing is the + // lobby's job. The page reads this object instead of assuming it is local. + assert.deepEqual(createLocalSession(1).capabilities, { + undo: true, + saveLocal: true, + newGame: true, + }); + }); +}); diff --git a/test/sim.test.ts b/test/sim.test.ts index 47422af..80c4283 100644 --- a/test/sim.test.ts +++ b/test/sim.test.ts @@ -13,7 +13,7 @@ import { TOTAL_ROLLING_STOCK } from '../src/engine/content.ts'; import type { GameConfig, GameState, OfficeArea } from '../src/engine/state.ts'; import type { Intent } from '../src/engine/intents.ts'; import { connectionsFor, exitsFrom, hasPort, joins, neighbour, opposite, variantsFor } from '../src/engine/track.ts'; -import { isOperationalRail } from '../src/engine/state.ts'; +import { isOperationalRail, turnOf } from '../src/engine/state.ts'; import type { Port } from '../src/engine/track.ts'; import { developerBot, playGame, randomBot } from '../src/sim/bot.ts'; import { simulate } from '../src/sim/harness.ts'; @@ -97,7 +97,7 @@ describe('trains complete their runs (regression)', () => { assert.ok(tray, `A/D track holds ${id}, which no longer exists`); assert.equal(tray.position.at, 'grid', `${id} is recorded at an Office but is elsewhere`); if (tray.position.at === 'grid') { - assert.equal(tray.position.owner, owner); + assert.equal(tray.position.seat, owner); } } } @@ -213,7 +213,7 @@ describe('the revenue chain works end to end (regression)', () => { const area = s.officeAreas.get(0)!; s.clock.phase = 'localOps'; s.clock.currentActor = 0; - s.turn.option = 'draw'; + turnOf(s, 0).option = 'draw'; // A north-diverging turnout on the Running Track, and the arc that climbs to meet it. The // column is captured BEFORE the lay: laying on the sign moves the sign outward, so reading // `limitsEast` again afterwards names the next square along, not the turnout. @@ -727,7 +727,7 @@ describe('the bot does not throw away its own freight (regression)', () => { } as never); s.clock.phase = 'localOps'; s.clock.currentActor = 0; - s.turn.option = 'freightAgent'; + turnOf(s, 0).option = 'freightAgent'; const jammed = area.grid.get('-1,0')!; assert.ok(!isOperationalRail(jammed), 'a jammed industry is not Operational Rail'); diff --git a/test/track.test.ts b/test/track.test.ts index 2812509..2aeaa2d 100644 --- a/test/track.test.ts +++ b/test/track.test.ts @@ -15,7 +15,7 @@ import type { TrackCard, TurnoutOrientation, } from '../src/engine/state.ts'; -import { coordKey, isOperationalRail } from '../src/engine/state.ts'; +import { coordKey, isOperationalRail, turnOf } from '../src/engine/state.ts'; import { createGame } from '../src/engine/setup.ts'; import { applyIntent, areaOf } from '../src/engine/apply.ts'; import type { MoveContext, Occupancy, Port } from '../src/engine/track.ts'; @@ -134,7 +134,7 @@ function areaFrom(cards: Record, officeCoord: GridCoord): Off const grid = new Map(); for (const [k, v] of Object.entries(cards)) grid.set(k, v); return { - owner: 0, + seat: 0, tier: 'whistlePost', grid, officeCoord, @@ -682,12 +682,12 @@ describe('coupling lifts only the cars the crew ran over', () => { // A local CREW, which is what this test is about — `trainNumber: null`. It used to say train 1, // the Crack Limited, which prints "no switching" (§7) and may not make this move at all. id: 'crew', trainNumber: null, trainIsExtra: false, engineAt: 0, consist: [], - direction: 'west', facing: 'w', position: { at: 'grid', owner: 0, coord: { row: 0, col: 0 } }, movesUsed: 0, + direction: 'west', facing: 'w', position: { at: 'grid', seat: 0, coord: { row: 0, col: 0 } }, movesUsed: 0, }); s.clock.phase = 'localOps'; s.clock.currentActor = 0; - s.turn.option = 'switch'; - s.turn.movesRemaining = 6; + turnOf(s, 0).option = 'switch'; + turnOf(s, 0).movesRemaining = 6; const r = applyIntent(s, 0, { type: 'switch.move', trayId: 'crew', to: { row: 0, col: -1 }, reverse: false }); assert.ok(r.ok, 'the move should be legal'); @@ -927,12 +927,12 @@ describe('backing up does not turn the train around', () => { s.trays.set(id, { id, trainNumber: null, trainIsExtra: false, engineAt: 0, consist: [], direction: 'east', facing: 'e', - position: { at: 'grid', owner: 0, coord: at(0, 1) }, movesUsed: 0, + position: { at: 'grid', seat: 0, coord: at(0, 1) }, movesUsed: 0, } as never); s.clock.phase = 'localOps'; s.clock.currentActor = 0; - s.turn.option = 'switch'; - s.turn.movesRemaining = 6; + turnOf(s, 0).option = 'switch'; + turnOf(s, 0).movesRemaining = 6; // Running forward, east: the engine leads, so it still points east. assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 2), reverse: false }).ok); diff --git a/test/train-rules.test.ts b/test/train-rules.test.ts index 094b514..76826d5 100644 --- a/test/train-rules.test.ts +++ b/test/train-rules.test.ts @@ -14,7 +14,7 @@ import { applyIntent, areaOf, check } from '../src/engine/apply.ts'; import { ALL_TRAINS, trainProfile } from '../src/engine/content.ts'; import { createGame } from '../src/engine/setup.ts'; import type { GameConfig, GameState, GridCoord, RollingStock, TrackCard } from '../src/engine/state.ts'; -import { coordKey } from '../src/engine/state.ts'; +import { coordKey, turnOf } from '../src/engine/state.ts'; import { trainRules } from '../src/sim/view.ts'; const config: GameConfig = { @@ -54,12 +54,12 @@ function switching( area.grid.set(coordKey({ row: office.row, col: office.col - 3 }), straight()); s.trays.set('t', { id: 't', trainNumber, trainIsExtra: isExtra, engineAt: 0, consist, - direction: 'west', facing: 'w', position: { at: 'grid', owner: 0, coord: here }, movesUsed: 0, + direction: 'west', facing: 'w', position: { at: 'grid', seat: 0, coord: here }, movesUsed: 0, }); s.clock.phase = 'localOps'; s.clock.currentActor = 0; - s.turn.option = 'switch'; - s.turn.movesRemaining = 6; + turnOf(s, 0).option = 'switch'; + turnOf(s, 0).movesRemaining = 6; return 't'; } @@ -210,7 +210,7 @@ describe('§7 — trains Porters may not work', () => { }; s.trays.set('t', { id: 't', trainNumber, trainIsExtra: isExtra, engineAt: 0, consist: [coach(false)], - direction: 'east', position: { at: 'grid', owner: 0, coord: area.officeCoord }, movesUsed: 0, + direction: 'east', position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0, }); area.adOccupancy.push('t'); s.clock.phase = 'loadUnload'; diff --git a/test/web.test.ts b/test/web.test.ts index d74aa7a..6685e27 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -7,6 +7,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; +import { turnOf } from '../src/engine/state.ts'; import { execFileSync } from 'node:child_process'; import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; @@ -1520,6 +1521,27 @@ describe('the static build', () => { } }); + it('renders from the Frame and the Menu, never from GameState', () => { + /** + * THE PROPERTY THAT MAKES A REMOTE CLIENT POSSIBLE. + * + * `main.ts` used to reach into `game.state` in eleven places — the phase, the outcome, another + * player's name, the hand count. That is free with the engine in the same process and impossible + * with a server, where the client holds no state at all: it has neither the deck order nor + * anyone else's hand, and could not be given them without handing over the game. + * + * Everything it needs now lives on `Frame` and `Menu`. This is a cheap guard on a property that + * is very easy to lose — one `game.state.clock.day` would compile, run, and quietly make the + * page unable to run against a server. See `docs/architecture/multiplayer.md` §5. + */ + const src = readFileSync(join(root, 'src/web/main.ts'), 'utf8'); + const reads = [...src.matchAll(/game\.state[.[]/g)]; + assert.equal( + reads.length, 0, + `main.ts reaches into game.state ${reads.length} time(s); it must render from Frame + Menu`, + ); + }); + it('asks only for elements the page actually has', () => { // Cheap and total: compare every $('id') in the source against the ids in the served HTML. // Getting this wrong does not degrade the page, it stops the game starting at all. @@ -1826,7 +1848,7 @@ describe('the static build', () => { { type: 'caboose', loaded: false }, ], direction: 'east', facing: 'e', - position: { at: 'grid', owner: 0, coord: { row: area.runningRow, col: 0 } }, movesUsed: 0, + position: { at: 'grid', seat: 0, coord: { row: area.runningRow, col: 0 } }, movesUsed: 0, } as never); const cell = view(game).cells.find((c) => c.row === area.runningRow && c.col === 0)!; @@ -1862,7 +1884,7 @@ describe('the static build', () => { { type: 'caboose', loaded: false }, ], direction: facing === 'e' ? 'east' : 'west', facing, - position: { at: 'grid', owner: 0, coord: { row: area.runningRow, col: 0 } }, movesUsed: 0, + position: { at: 'grid', seat: 0, coord: { row: area.runningRow, col: 0 } }, movesUsed: 0, } as never); const cell = view(game).cells.find((c) => c.row === area.runningRow && c.col === 0)!; const svg = officeSvg([cell], area.runningRow); @@ -1899,7 +1921,7 @@ describe('the static build', () => { id, trainNumber: null, trainIsExtra: false, engineAt: 1, consist: [{ type: 'boxcar', loaded: true }, { type: 'caboose', loaded: false }], direction: 'east', facing: 'e', - position: { at: 'grid', owner: 0, coord: { row: area.runningRow, col: 1 } }, movesUsed: 0, + position: { at: 'grid', seat: 0, coord: { row: area.runningRow, col: 1 } }, movesUsed: 0, } as never); const front = describeIntent(game.state, { type: 'switch.dropCars', trayId: id, count: 1, fromNose: true }); const back = describeIntent(game.state, { type: 'switch.dropCars', trayId: id, count: 1 }); @@ -2276,7 +2298,7 @@ describe('the hand limit is a limit, not a toll on drawing (regression)', () => if (currentActor(game) === null) break; const { options, groups } = actionGroups(game); if (groups.length === 0 || options.length === 0) break; - if (game.state.clock.phase === 'localOps' && game.state.turn.option === 'draw') { + if (game.state.clock.phase === 'localOps' && turnOf(game.state, 0).option === 'draw') { const engineAllows = options.some((o) => o.type === 'draw.end'); assert.equal( engineAllows,