v0.4.0 — multiplayer Phases 0 and 1: seat and player split apart, turn state per player, the page behind a Session, and eight seat/player mix-ups fixed with tests that fail without them

This commit is contained in:
Jesse
2026-08-13 14:06:02 -04:00
parent 216006b091
commit 49f8504b05
34 changed files with 1743 additions and 526 deletions
+79
View File
@@ -21,6 +21,85 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
## Unreleased ## 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<PlayerIndex, TurnState>`, 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 ## 0.3.1 — 2026-08-13
### Groundwork for multiplayer ### Groundwork for multiplayer
+5 -4
View File
@@ -10,7 +10,7 @@ train into an occupied Subdivision. Get that wrong and two trains meet at speed.
## Status ## 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. imports nothing outside itself, and never touches `Math.random`, so a static host is all it needs.
- **Rules** — fully specified. Ten gaps in the original prototype rules found and resolved. - **Rules** — 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. - **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 - **Code** — the engine, the bot, the balance harness, the replay viewer and the playable page. A
game can be saved, shared, replayed and stepped back through. game can be saved, shared, replayed and stepped back through.
- **Not built** — multiplayer (the engine runs 2–5 player games and the bot plays them, but there is - **Not built** — the multiplayer server (Phases 0 and 1 of the plan are done: seat and player are
no server, no turn submission and no per-player view), the 22 opponent-directed cards, and real separate, turn state is per player, and the page talks to a `Session` rather than to the engine, so
audio. 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 — 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 of which ~5.4 is the "one Revenue per train that clears your section" rule, so the working freight
+25 -2
View File
@@ -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 engine currently has no per-player turn within the New Train phase, so this is unbuilt rather
than wrong. 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.<domain>` 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 - [ ] **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 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 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 **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 wired and read, and fire only against these cards. Until then those three are dormant by
design rather than broken. design rather than broken.
- [ ] **Multiplayer proper.** The engine runs 2–5 player games and the bot plays them, but there is no - [ ] **Multiplayer proper — Phases 0 and 1 done (v0.4.0), Phases 2–6 to go.** The full plan is
server, no turn submission, and no per-player view. `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.
--- ---
+2 -2
View File
@@ -161,8 +161,8 @@ is what makes the whole system testable without a server.
**11. Transport** **11. Transport**
- **Where:** Server · **When:** per connection; pushes on every event - **Where:** Server · **When:** per connection; pushes on every event
- **Does:** HTTP for lobby operations, WebSocket for the ordered event stream, static asset serving. - **Does:** HTTP POST for lobby operations and intents, SSE for the ordered push stream, static asset
Bidirectional — most traffic is server → client. serving ([`multiplayer.md`](multiplayer.md) D5). Most traffic is server → client.
- **MVP: S** · **Final: M** · ~150 → ~400 LOC - **MVP: S** · **Final: M** · ~150 → ~400 LOC
- **Size driver:** small at MVP because one client needs no fan-out. Grows with broadcast, backpressure - **Size driver:** small at MVP because one client needs no fan-out. Grows with broadcast, backpressure
and reconnect-with-replay. and reconnect-with-replay.
+2 -2
View File
@@ -15,7 +15,7 @@ The requirements are modest, which is what makes both paths viable:
| Need | Why | | Need | Why |
| --- | --- | | --- | --- |
| One long-running process | Active games are held in memory ([`overview.md`](overview.md)) | | One long-running process | Active games are held in memory ([`overview.md`](overview.md)) |
| 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)) | | A writable data directory | The append-only event log ([`lobby-and-sessions.md`](lobby-and-sessions.md)) |
| Static asset serving | The browser client | | Static asset serving | The browser client |
| No outbound network access | The game talks to nobody | | No outbound network access | The game talks to nobody |
@@ -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. something the application chooses or should claim.
**One thing the architecture must respect.** A packaged service should not assume it is reachable at **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 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 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. plain web host and is required on StartOS, so it should simply be the rule.
+432
View File
@@ -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<ok> 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<PlayerIndex, TurnState>`, 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<PlayerIndex, TurnState>`, 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.
+10 -7
View File
@@ -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 That means the server must push. A request/response API alone would leave four players polling to
watch a fifth switch cars. watch a fifth switch cars.
**WebSocket, with the HTTP endpoints alongside it** for lobby operations (list games, create, join) **Server-Sent Events, with HTTP POST alongside** for lobby operations and for intents, where a
where a request/response shape is the natural fit. Server-Sent Events would also serve, since the request/response shape is the natural fit. This paragraph originally leaned the other way, toward
push is nearly one-directional and intents are rare enough to send over HTTP — worth keeping in mind WebSocket with SSE as the fallback; it was settled the other way in
if the eventual stack makes SSE materially simpler. [`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 What matters more than the choice: **every state change reaches clients on one ordered stream**, and
stream**. Clients apply events in order and never mutate state locally in a way that could drift. 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 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 The rules engine being pure and deterministic is what makes the whole thing testable — you can drive
+82 -213
View File
@@ -1,269 +1,138 @@
# Protocol # Protocol
The client↔server message vocabulary, and how per-player views are redacted. Written against The client↔server message vocabulary, and how per-player views are redacted.
[`../rules/rules-v0.2.md`](../rules/rules-v0.2.md) and [`game-state.md`](game-state.md).
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. | Family | Direction | Authority |
- **Event** — server → clients. A fact. Ordered, and the game's history. | --- | --- | --- |
- **View** — server → one client. A redacted state snapshot. | **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 Read those files for the shapes. Each variant carries its own doc comment explaining the rule behind
is fixed by the rules. 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 ## 1. Intents
Every intent carries `gameId`, `playerId`, and a `seq` the server uses to reject duplicates from a **Intents are already the wire format.** A saved game is `{ seed, history: Intent[] }` and `fromSave`
reconnecting client. 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 ### What is deliberately NOT an intent
Stage.
``` These are the places where offering a choice would be a rules violation, and they are worth stating
LocalOps.Choose { option: switch | draw | freightAgent } 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 - **Picking up cars.** Moving into standing cars couples them automatically and mandatorily (§A.4).
during night Stages (Appendix B). 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.
``` ### Two loops the client must not flatten
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
```
Pick-up is **not** an intent. Moving into standing cars couples them automatically and mandatorily - **New Train cycles.** §7 goes round the table one car per player per pass until the consist is full
(§A.4) — the server does it as part of resolving `Switch.Move`. Offering a choice would be a rules or no suitable car remains. It is *not* one prompt per player per train. At five or more players a
violation. round can end mid-pass with players never prompted.
- **`newTrain.passCar` is validated, not trusted.** §7 requires "every effort to find a suitable car",
`Switch.DropCars` takes a count, not a set of car ids: cars come off in seated order (§A.3), so the so the engine checks the Division Yard against the consist spec and refuses a pass when a legal car
only free choice is how many. is there.
**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
```
--- ---
## 2. Rejections ## 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.
``` A client may pre-validate to grey out illegal moves — that is what `legalActions` is for, and it is
Rejected { seq, code, message, ... } 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.**
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.
--- ---
## 3. Events ## 3. Events
Events are the authoritative history. State is `fold(events)`, which is what gives reconnection, Events are the authoritative history: `state = fold(events)`, which buys reconnection, persistence and
persistence and replay for one design decision. 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.
``` Two consequences worth knowing before adding an event:
StageBegan { day, stage }
PhaseBegan { phase }
ActorChanged { playerIndex | null }
SuperintendentChanged { playerIndex }
DayEnded { day, revenues }
```
**Local operations** - **`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
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. 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 ## 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 | | State | Visibility |
| --- | --- | | --- | --- |
| Board: track grids, Offices, Limits, trains, standing cars | **Public** | | Board: track grids, Offices, Limits, trains, standing cars | **Public** |
| Facilities: boxes, `MEN AT WORK`, Laborer/Porter usage | **Public** | | Facilities: boxes, `MEN AT WORK`, Laborer/Porter usage | **Public** |
| Division Yard, Classification Yard contents | **Public** — physical piles on the table | | 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** | | Department slots (the three face-up cards) | **Public** |
| Home Office deck **contents and order** | **Secret** — never sent, to anyone | | Home Office deck **contents and order** | **Secret** — never sent, to anyone |
| Home Office deck **count** | Public — players can see the pile's height | | Home Office deck **count** | Public — players can see the pile's height |
| A player's hand | **Owner only**; others see the count | | 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 | | 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 |
``` **The redaction surface is four fields, not sixty event types** — `seed`, `rngState`,
View { `deckReshuffled.order`, and `cardDrawn.cardId` when the draw was from the Home Office. The full
public : PublicState -- identical for every client argument, including why `trainScheduled` is public despite being a die roll, is in
private : { hand: CardId[], redFlag: boolean } [`multiplayer.md` §7](multiplayer.md). It reduces to: **call `snapshot` and never send `GameState`.**
you : PlayerIndex
canAct : boolean
legalIntents? : IntentSummary[] -- optional server-computed affordances
}
```
**Two redaction traps.** First, `CardDrawn` from the Home Office must omit `cardId` for every client **This is enforced, not assumed.** The redaction test is the single most important test in the
except the drawer — the obvious version of this event leaks the draw to the table. Second, the deck multiplayer work: everything else degrades gracefully, a redaction bug hands a player 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.
--- ---
## 5. Ordering and idempotency ## 5. Ordering and idempotency
- Events carry a monotonic `eventSeq` per game. Clients apply strictly in order and request a replay - Events carry a monotonic sequence per game. Clients apply strictly in order and request a replay on
on a gap rather than guessing. a gap rather than guessing.
- Intents carry a client `seq`. The server ignores a repeat of one it has already applied, so a - 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. 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 - **The server never applies two intents concurrently within a game.** A per-game queue is sufficient
free — a per-game queue is sufficient and there is no need for anything cleverer at this scale. 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<PlayerIndex, TurnState>`) means several
players may hold an open turn at once, and their intents still land one at a time.
+1
View File
@@ -45,6 +45,7 @@ must do.
| [`architecture/protocol.md`](architecture/protocol.md) | Intents, events, and per-player view redaction. | | [`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/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/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 ## Current status
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "station-master", "name": "station-master",
"version": "0.3.1", "version": "0.4.0",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Station Master — a railroad operations game", "description": "Station Master — a railroad operations game",
+48 -31
View File
@@ -36,10 +36,10 @@ import type { Direction } from './content.ts';
import type { GameEvent } from './events.ts'; import type { GameEvent } from './events.ts';
// `trainNeedingCars` lives in apply.ts beside `check`'s copy of the same question, so the phase and // `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. // 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 { legalActions } from './legal.ts';
import type { CrewTray, DivisionNode, GameState, PlayerIndex, RollingStock, TrayId } from './state.ts'; import type { CrewTray, DivisionNode, GameState, PlayerIndex, RollingStock, SeatIndex, TrayId } from './state.ts';
import { coordKey, freshTurn, subdivisions, totalRevenue } from './state.ts'; import { coordKey, freshTurns, playerAtSeat, subdivisions, totalRevenue, turnOf } from './state.ts';
export type AdvanceResult = { export type AdvanceResult = {
events: GameEvent[]; events: GameEvent[];
@@ -61,8 +61,8 @@ function movesForStage(s: GameState): number {
// Division navigation // Division navigation
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function nodeIndexOfOffice(s: GameState, owner: PlayerIndex): number { function nodeIndexOfOffice(s: GameState, seat: SeatIndex): number {
return s.division.nodes.findIndex((n) => n.kind === 'office' && n.owner === owner); return s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === seat);
} }
const step = (d: Direction): number => (d === 'east' ? 1 : -1); const step = (d: Direction): number => (d === 'east' ? 1 : -1);
@@ -101,31 +101,41 @@ function playerPhase(
events: GameEvent[], events: GameEvent[],
phase: 'localOps' | 'loadUnload', phase: 'localOps' | 'loadUnload',
): AdvanceResult { ): 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). // 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) { if (phase === 'localOps' && turn.option === 'freightAgent' && turn.freightAgentUsed) {
s.turn.done = true; turn.done = true;
} }
// Spending the last Move ends a switching turn without needing an explicit end (§6.1). // 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) { if (phase === 'localOps' && turn.option === 'switch' && turn.movesRemaining === 0) {
s.turn.done = true; turn.done = true;
} }
if (!s.turn.done) { if (!turn.done) {
s.clock.currentActor = actorAt(s, s.clock.actorOffset); s.clock.currentActor = actor;
// SAFETY NET. If the actor has no legal action at all, the turn ends rather than deadlocking. // 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) — // 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 // 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. // visibly, and a hung game is far harder to diagnose than a forfeited turn.
if (legalActions(s, s.clock.currentActor).length === 0) { if (legalActions(s, s.clock.currentActor).length === 0) {
s.turn.done = true; turn.done = true;
} else { } else {
return { events, needsInput: true }; return { events, needsInput: true };
} }
} }
s.clock.actorOffset += 1; s.clock.actorOffset += 1;
s.turn = freshTurn(movesForStage(s));
if (s.clock.actorOffset >= s.players.length) { if (s.clock.actorOffset >= s.players.length) {
return { events: [...events, ...enterPhase(s, nextPhase(phase))], needsInput: false }; 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[] { function enterPhase(s: GameState, phase: GameState['clock']['phase']): GameEvent[] {
s.clock.phase = phase; s.clock.phase = phase;
s.clock.actorOffset = 0; 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); s.clock.currentActor = phase === 'mainline' ? null : actorAt(s, 0);
return [ return [
{ type: 'phaseBegan', phase }, { 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)); (tray.position.coord.row === where.coord.row && tray.position.coord.col === where.coord.col));
if (moved !== 'expedited' && stillThere) { if (moved !== 'expedited' && stillThere) {
tray.stopPointClaimed = true; 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 = const label =
tray.position.at === 'grid' tray.position.at === 'grid'
? `(${tray.position.coord.row},${tray.position.coord.col})` ? `(${tray.position.coord.row},${tray.position.coord.col})`
@@ -431,8 +444,9 @@ function spendDispatchBonus(
const mine = tray.trainNumber ?? 99; const mine = tray.trainNumber ?? 99;
const theirs = other.trainNumber ?? 99; const theirs = other.trainNumber ?? 99;
const area = s.officeAreas.get(s.clock.superintendent); // The Fedora is held by a PLAYER, and the dispatch devices are installed in an Office Area, which
if (!area) return 0; // 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). // Best device first — Radio (+12) beats Telephone (+8) beats Telegraph (+4).
for (const key of ['radio', 'telephone', 'telegraph'] as const) { 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 // 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. // colliding with every train that follows it.
if (tray.position.at === 'grid') { if (tray.position.at === 'grid') {
const owner = tray.position.owner; const seat = tray.position.seat;
const area = areaOf(s, owner); const area = areaAtSeat(s, seat);
// Only a train at the Office itself is eligible; one on Secondary Track is not (§8.1, Gap 2b). // Only a train at the Office itself is eligible; one on Secondary Track is not (§8.1, Gap 2b).
if ( if (
tray.position.coord.row !== area.officeCoord.row || tray.position.coord.row !== area.officeCoord.row ||
@@ -533,7 +547,7 @@ function moveTrain(
return 'held'; return 'held';
} }
const officeIndex = nodeIndexOfOffice(s, owner); const officeIndex = nodeIndexOfOffice(s, seat);
const target = officeIndex + dir; const target = officeIndex + dir;
const node = s.division.nodes[target]; const node = s.division.nodes[target];
if (!node) return 'held'; if (!node) return 'held';
@@ -551,7 +565,7 @@ function moveTrain(
from: 'the Office', from: 'the Office',
to: 'the Mainline', to: 'the Mainline',
}); });
awardDeparture(s, owner, tray, events); awardDeparture(s, seat, tray, events);
return 'moved'; return 'moved';
} }
@@ -566,7 +580,7 @@ function moveTrain(
*/ */
if (node.kind === 'divisionPoint') { if (node.kind === 'divisionPoint') {
area.adOccupancy = area.adOccupancy.filter((t) => t !== id); area.adOccupancy = area.adOccupancy.filter((t) => t !== id);
awardDeparture(s, owner, tray, events); awardDeparture(s, seat, tray, events);
retireTrain(s, id, tray, events); retireTrain(s, id, tray, events);
return 'moved'; return 'moved';
} }
@@ -654,7 +668,7 @@ function moveTrain(
} }
if (dest.kind === 'office') { 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, s: GameState,
id: TrayId, id: TrayId,
tray: CrewTray, tray: CrewTray,
owner: PlayerIndex, seat: SeatIndex,
events: GameEvent[], events: GameEvent[],
): MoveOutcome { ): MoveOutcome {
const area = areaOf(s, owner); const area = areaAtSeat(s, seat);
const capacity = officeProfile(area.tier).adTracks; const capacity = officeProfile(area.tier).adTracks;
const hasEnhancement = (key: string): boolean => const hasEnhancement = (key: string): boolean =>
[...area.grid.values()].some((c) => c.enhancements.includes(key)); [...area.grid.values()].some((c) => c.enhancements.includes(key));
@@ -773,7 +787,7 @@ function arriveAtOffice(
for (const [key, card] of area.grid) { for (const [key, card] of area.grid) {
if (!card.enhancements.includes('yardOffice')) continue; if (!card.enhancements.includes('yardOffice')) continue;
const [row, col] = key.split(',').map(Number); 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({ events.push({
type: 'trainDiverted', type: 'trainDiverted',
trainNumber: tray.trainNumber ?? 0, trainNumber: tray.trainNumber ?? 0,
@@ -799,7 +813,7 @@ function arriveAtOffice(
}); });
return 'moved'; 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'; return 'moved';
} }
@@ -808,7 +822,7 @@ function arriveAtOffice(
const first = area.heldAtLimits.shift()!; const first = area.heldAtLimits.shift()!;
area.adOccupancy.push(first); area.adOccupancy.push(first);
const held = s.trays.get(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); 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. // is not expecting them (§A.4), so this is a collision too, not a coupling.
const officeCard = area.grid.get(coordKey(area.officeCoord)); const officeCard = area.grid.get(coordKey(area.officeCoord));
if (officeCard && officeCard.standing.length > 0) { 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'; return 'moved';
} }
area.adOccupancy.push(id); area.adOccupancy.push(id);
tray.position = { at: 'grid', owner, coord: area.officeCoord }; tray.position = { at: 'grid', seat, coord: area.officeCoord };
events.push({ events.push({
type: 'trainArrived', type: 'trainArrived',
trainNumber: tray.trainNumber ?? 0, trainNumber: tray.trainNumber ?? 0,
@@ -913,11 +927,14 @@ function collide(
*/ */
function awardDeparture( function awardDeparture(
s: GameState, s: GameState,
owner: PlayerIndex, seat: SeatIndex,
tray: CrewTray, tray: CrewTray,
events: GameEvent[], events: GameEvent[],
): void { ): void {
if (tray.trainNumber === null) return; 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]; const p = s.players[owner];
if (!p) return; if (!p) return;
p.revenue += 1; p.revenue += 1;
+74 -50
View File
@@ -43,13 +43,14 @@ import type {
OfficeArea, OfficeArea,
PlayerIndex, PlayerIndex,
RollingStock, RollingStock,
SeatIndex,
TrackArc, TrackArc,
TrackCard, TrackCard,
TrayId, TrayId,
TurnoutOrientation, TurnoutOrientation,
} from './state.ts'; } from './state.ts';
import { createRng } from './rng.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 type { MoveBlock, Occupancy, Port } from './track.ts';
import { import {
canDropCarsAt, canDropCarsAt,
@@ -70,9 +71,21 @@ export type ApplyResult =
// Lookup helpers // 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 { export function areaOf(s: GameState, player: PlayerIndex): OfficeArea {
const a = s.officeAreas.get(player); return areaAtSeat(s, seatOf(s, player));
if (!a) throw new Error(`no Office Area for player ${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; return a;
} }
@@ -176,7 +189,7 @@ export function facilityCarType(f: Facility): CarType | null {
export function hasSwitchOption(s: GameState, player: PlayerIndex): boolean { export function hasSwitchOption(s: GameState, player: PlayerIndex): boolean {
for (const tray of s.trays.values()) { 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; 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 * 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. * 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 { function rulesOf(tray: CrewTray): TrainRules {
if (tray.trainNumber === null) return {}; if (tray.trainNumber === null) return {};
return trainProfile(tray.trainNumber, tray.trainIsExtra)?.rules ?? {}; return trainProfile(tray.trainNumber, tray.trainIsExtra)?.rules ?? {};
@@ -374,12 +392,13 @@ const isFreight = (c: RollingStock): boolean => c.type !== 'coach' && c.type !==
*/ */
function freightBudgetLeft( function freightBudgetLeft(
s: GameState, s: GameState,
player: PlayerIndex,
tray: CrewTray, tray: CrewTray,
at: GridCoord, at: GridCoord,
wanted: number, wanted: number,
): boolean { ): boolean {
if (!rulesOf(tray).oneFreightPerLocation) return true; 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; return already + wanted <= 1;
} }
@@ -403,6 +422,7 @@ function switchingRefusal(tray: CrewTray): RejectionCode | null {
*/ */
function spendFreightBudget( function spendFreightBudget(
s: GameState, s: GameState,
player: PlayerIndex,
tray: CrewTray, tray: CrewTray,
at: GridCoord, at: GridCoord,
stock: readonly RollingStock[], stock: readonly RollingStock[],
@@ -410,8 +430,9 @@ function spendFreightBudget(
if (!rulesOf(tray).oneFreightPerLocation) return; if (!rulesOf(tray).oneFreightPerLocation) return;
const n = stock.filter(isFreight).length; const n = stock.filter(isFreight).length;
if (n === 0) return; if (n === 0) return;
const turn = turnOf(s, player);
const key = freightWorkedKey(tray.id, at); 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. */ /** 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 { function refusesThisOffice(s: GameState, player: PlayerIndex, tray: CrewTray): boolean {
if (!rulesOf(tray).terminalsOnly) return false; if (!rulesOf(tray).terminalsOnly) return false;
const area = s.officeAreas.get(player); return areaOf(s, player).tier !== 'terminal';
return !area || area.tier !== 'terminal';
} }
/** /**
@@ -466,7 +486,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
// -- Local Operations ----------------------------------------------------- // -- Local Operations -----------------------------------------------------
case 'localOps.choose': case 'localOps.choose':
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; 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). // 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 === 'freightAgent' && !hasFreightAgentOption(s, player)) return 'NO_SUCH_FACILITY';
if (i.option === 'switch' && !hasSwitchOption(s, player)) return 'NO_SUCH_TRAY'; 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': { case 'switch.move': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; 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';
if (s.turn.movesRemaining < 1) return 'NO_MOVES_REMAINING'; if (turnOf(s, player).movesRemaining < 1) return 'NO_MOVES_REMAINING';
const tray = s.trays.get(i.trayId); const tray = s.trays.get(i.trayId);
if (!tray) return 'NO_SUCH_TRAY'; if (!tray) return 'NO_SUCH_TRAY';
const noSwitch = switchingRefusal(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.dropOnly) return 'PICKUP_NOT_ALLOWED';
if (rules.pickUpEmptiesOnly && dest.couples.some((c) => c.loaded)) return 'EMPTIES_ONLY'; if (rules.pickUpEmptiesOnly && dest.couples.some((c) => c.loaded)) return 'EMPTIES_ONLY';
const freight = dest.couples.filter(isFreight).length; 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; return null;
} }
case 'switch.dropCars': { case 'switch.dropCars': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; 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); const tray = s.trays.get(i.trayId);
if (!tray) return 'NO_SUCH_TRAY'; if (!tray) return 'NO_SUCH_TRAY';
const noSwitch = switchingRefusal(tray); const noSwitch = switchingRefusal(tray);
@@ -544,7 +564,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
return 'COACH_MUST_STAY'; return 'COACH_MUST_STAY';
} }
const droppedFreight = cut.filter(isFreight).length; 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 'FREIGHT_WORKED_HERE';
} }
return canDropCarsAt(areaOf(s, player), here, i.count) ? null : 'CANNOT_DROP_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': { case 'switch.sortConsist': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; 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';
if (s.turn.movesRemaining < 1) return 'NO_MOVES_REMAINING'; if (turnOf(s, player).movesRemaining < 1) return 'NO_MOVES_REMAINING';
const tray = s.trays.get(i.trayId); const tray = s.trays.get(i.trayId);
if (!tray) return 'NO_SUCH_TRAY'; if (!tray) return 'NO_SUCH_TRAY';
const noSwitch = switchingRefusal(tray); const noSwitch = switchingRefusal(tray);
@@ -573,26 +593,26 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
case 'switch.end': case 'switch.end':
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; 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 ---------------------------------------------------------- // -- Draw a card ----------------------------------------------------------
case 'draw.fromHomeOffice': case 'draw.fromHomeOffice':
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; 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';
if (s.turn.drawnThisTurn) return 'OPTION_ALREADY_CHOSEN'; if (turnOf(s, player).drawnThisTurn) return 'OPTION_ALREADY_CHOSEN';
if (s.decks.homeOffice.length === 0) return 'DECK_EMPTY'; if (s.decks.homeOffice.length === 0) return 'DECK_EMPTY';
return null; return null;
case 'draw.fromDepartment': case 'draw.fromDepartment':
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; 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';
if (s.turn.drawnThisTurn) return 'OPTION_ALREADY_CHOSEN'; if (turnOf(s, player).drawnThisTurn) return 'OPTION_ALREADY_CHOSEN';
if (i.slot < 0 || i.slot > 2) return 'SLOT_EMPTY'; if (i.slot < 0 || i.slot > 2) return 'SLOT_EMPTY';
return (s.decks.departments[i.slot]?.length ?? 0) > 0 ? null : 'SLOT_EMPTY'; return (s.decks.departments[i.slot]?.length ?? 0) > 0 ? null : 'SLOT_EMPTY';
case 'card.play': { case 'card.play': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; 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) ?? []; const hand = s.decks.hands.get(player) ?? [];
if (!hand.includes(i.cardId)) return 'CARD_NOT_IN_HAND'; if (!hand.includes(i.cardId)) return 'CARD_NOT_IN_HAND';
return checkPlay(s, player, i.cardId, i.placement, i.variant, i.node); 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': { case 'mainline.modify': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; 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); const card = s.cards.get(i.cardId);
if (!card || !(s.decks.hands.get(player) ?? []).includes(i.cardId)) return 'NO_SUCH_CARD'; if (!card || !(s.decks.hands.get(player) ?? []).includes(i.cardId)) return 'NO_SUCH_CARD';
if (card.kind.kind !== 'mainlineModifier') return 'WRONG_INTENT'; 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': { case 'maneuver.flyingSwitch': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; 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';
if (s.turn.movesRemaining < 1) return 'NO_MOVES_REMAINING'; if (turnOf(s, player).movesRemaining < 1) return 'NO_MOVES_REMAINING';
const card = s.cards.get(i.cardId); const card = s.cards.get(i.cardId);
if (!card || !(s.decks.hands.get(player) ?? []).includes(i.cardId)) return 'NO_SUCH_CARD'; 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'; 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': { case 'draw.end': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; 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". // §6.2 — "the player must reduce his hand to no more than three cards".
const hand = s.decks.hands.get(player) ?? []; const hand = s.decks.hands.get(player) ?? [];
const limit = s.decks.redFlags.get(player) ? HAND_LIMIT + 1 : HAND_LIMIT; 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 -------------------------------------------------------- // -- Freight Agent --------------------------------------------------------
case 'freightAgent.stockOutbound': { case 'freightAgent.stockOutbound': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (s.turn.option !== 'freightAgent') return 'OPTION_NOT_CHOSEN'; if (turnOf(s, player).option !== 'freightAgent') return 'OPTION_NOT_CHOSEN';
if (s.turn.freightAgentUsed) return 'OPTION_ALREADY_CHOSEN'; if (turnOf(s, player).freightAgentUsed) return 'OPTION_ALREADY_CHOSEN';
const f = facilityAt(s, player, i.at); const f = facilityAt(s, player, i.at);
if (!f) return 'NO_SUCH_FACILITY'; if (!f) return 'NO_SUCH_FACILITY';
if (!f.allows.outbound) 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': { case 'freightAgent.clearInbound': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (s.turn.option !== 'freightAgent') return 'OPTION_NOT_CHOSEN'; if (turnOf(s, player).option !== 'freightAgent') return 'OPTION_NOT_CHOSEN';
if (s.turn.freightAgentUsed) return 'OPTION_ALREADY_CHOSEN'; if (turnOf(s, player).freightAgentUsed) return 'OPTION_ALREADY_CHOSEN';
const f = facilityAt(s, player, i.at); const f = facilityAt(s, player, i.at);
if (!f) return 'NO_SUCH_FACILITY'; if (!f) return 'NO_SUCH_FACILITY';
return f.inboundBox[i.index] ? null : 'BOX_EMPTY'; 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 // §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 // action unspent is a wasted Stage, which is the player's to waste — the alternative was
// forcing an unjam that destroys a load. // 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': { case 'freightAgent.unjam': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (s.turn.option !== 'freightAgent') return 'OPTION_NOT_CHOSEN'; if (turnOf(s, player).option !== 'freightAgent') return 'OPTION_NOT_CHOSEN';
if (s.turn.freightAgentUsed) return 'OPTION_ALREADY_CHOSEN'; if (turnOf(s, player).freightAgentUsed) return 'OPTION_ALREADY_CHOSEN';
const f = facilityAt(s, player, i.at); const f = facilityAt(s, player, i.at);
if (!f) return 'NO_SUCH_FACILITY'; if (!f) return 'NO_SUCH_FACILITY';
if (i.from === 'menAtWork') return f.menAtWork?.[i.index] ? null : 'BOX_EMPTY'; 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, trayId: i.trayId,
from, from,
to: i.to, to: i.to,
movesRemaining: s.turn.movesRemaining - 1, movesRemaining: turnOf(s, player).movesRemaining - 1,
/** /**
* A TRAIN THAT BACKS UP HAS NOT TURNED AROUND. * 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 { export function reduce(s: GameState, e: GameEvent): void {
switch (e.type) { switch (e.type) {
case 'localOpsOptionChosen': case 'localOpsOptionChosen':
s.turn.option = e.option; turnOf(s, e.player).option = e.option;
break; break;
case 'trayMoved': { case 'trayMoved': {
const tray = s.trays.get(e.trayId)!; const tray = s.trays.get(e.trayId)!;
const owner = tray.position.at === 'grid' ? tray.position.owner : 0; // A tray moving stays in the district it was already in — the seat does not change.
tray.position = { at: 'grid', owner, coord: e.to }; 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; 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). // 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. // 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 = const atOffice =
e.to.row === area.officeCoord.row && e.to.col === area.officeCoord.col; e.to.row === area.officeCoord.row && e.to.col === area.officeCoord.col;
area.adOccupancy = area.adOccupancy.filter((t) => t !== e.trayId); area.adOccupancy = area.adOccupancy.filter((t) => t !== e.trayId);
@@ -1388,7 +1411,7 @@ export function reduce(s: GameState, e: GameEvent): void {
case 'carsCoupled': { case 'carsCoupled': {
const tray = s.trays.get(e.trayId)!; 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) { if (e.toNose) {
// Cars taken on the nose go AHEAD of the engine — "pushing them into the Facility" — so the // 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 // 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 = []; card.standing = [];
if (card.facility) card.facility.industryTrack.cars = []; 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; 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. // to use one: §8.2 will not let a train leave the Office with cars in front of its engine.
tray.engineAt = 0; tray.engineAt = 0;
// "Spends one move in the yard" — the sort costs a Move. // "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; break;
} }
case 'carsDropped': { case 'carsDropped': {
const tray = s.trays.get(e.trayId)!; 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) { if (e.fromNose) {
// Off the front: everything ahead of the engine shortens, so the engine moves up by that // 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. // 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)); const card = area.grid.get(coordKey(e.at));
// On a Facility card the industry track is where cars stand (§9.3). // On a Facility card the industry track is where cars stand (§9.3).
if (card) carsOn(card).push(...e.stock); 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; break;
} }
@@ -1469,7 +1493,7 @@ export function reduce(s: GameState, e: GameEvent): void {
else if (e.slot !== undefined) s.decks.departments[e.slot]!.pop(); else if (e.slot !== undefined) s.decks.departments[e.slot]!.pop();
hand.push(e.cardId); hand.push(e.cardId);
s.decks.hands.set(e.player, hand); s.decks.hands.set(e.player, hand);
s.turn.drawnThisTurn = true; turnOf(s, e.player).drawnThisTurn = true;
break; break;
} }
@@ -1532,7 +1556,7 @@ export function reduce(s: GameState, e: GameEvent): void {
if (track) track.cars.push(...e.stock); if (track) track.cars.push(...e.stock);
else card.standing.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); spendCard(s, e.player, e.cardId);
break; break;
} }
@@ -1588,7 +1612,7 @@ export function reduce(s: GameState, e: GameEvent): void {
if (idx >= 0) s.yards.divisionYard.splice(idx, 1); if (idx >= 0) s.yards.divisionYard.splice(idx, 1);
refillDivisionYardIfEmpty(s); refillDivisionYardIfEmpty(s);
f.outboundBox.push(e.stock); f.outboundBox.push(e.stock);
s.turn.freightAgentUsed = true; turnOf(s, e.player).freightAgentUsed = true;
break; 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); const idx = f.inboundBox.findIndex((c) => c.type === e.stock.type && c.loaded === e.stock.loaded);
if (idx >= 0) f.inboundBox.splice(idx, 1); if (idx >= 0) f.inboundBox.splice(idx, 1);
s.yards.classificationYard.push(e.stock); s.yards.classificationYard.push(e.stock);
s.turn.freightAgentUsed = true; turnOf(s, e.player).freightAgentUsed = true;
break; break;
} }
@@ -1612,7 +1636,7 @@ export function reduce(s: GameState, e: GameEvent): void {
if (idx >= 0) box.splice(idx, 1); if (idx >= 0) box.splice(idx, 1);
} }
s.yards.classificationYard.push(e.stock); s.yards.classificationYard.push(e.stock);
s.turn.freightAgentUsed = true; turnOf(s, e.player).freightAgentUsed = true;
break; break;
} }
@@ -1753,7 +1777,7 @@ export function reduce(s: GameState, e: GameEvent): void {
case 'phaseEnded': case 'phaseEnded':
// The actor has finished; the phase driver moves on to the next player. // 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; break;
case 'clearanceGiven': case 'clearanceGiven':
+3 -2
View File
@@ -17,6 +17,7 @@ import { enhancementRule } from './content.ts';
import { check, areaOf, destinationsFor } from './apply.ts'; import { check, areaOf, destinationsFor } from './apply.ts';
import type { Intent } from './intents.ts'; import type { Intent } from './intents.ts';
import type { GameState, GridCoord, PlayerIndex } from './state.ts'; import type { GameState, GridCoord, PlayerIndex } from './state.ts';
import { seatOf } from './state.ts';
import { variantsFor } from './track.ts'; import { variantsFor } from './track.ts';
const CAR_TYPES: readonly CarType[] = ['coach', 'boxcar', 'reefer', 'hopper', 'tank', 'caboose']; 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) // -- switch (§6.1)
for (const [trayId, tray] of s.trays) { 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; const from = tray.position.coord;
for (const reverse of [false, true]) { for (const reverse of [false, true]) {
for (const d of destinationsFor(s, player, trayId, from, reverse)) { 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; const k = s.cards.get(cardId)?.kind;
if (k?.kind !== 'maneuver' || k.key !== 'flyingSwitch') continue; if (k?.kind !== 'maneuver' || k.key !== 'flyingSwitch') continue;
for (const [trayId, tray] of s.trays) { 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; const from = tray.position.coord;
for (const reverse of [false, true]) { for (const reverse of [false, true]) {
for (const d of destinationsFor(s, player, trayId, from, reverse)) { for (const d of destinationsFor(s, player, trayId, from, reverse)) {
+13 -7
View File
@@ -34,6 +34,7 @@ import type {
Card, Card,
CardId, CardId,
DivisionNode, DivisionNode,
SeatIndex,
GameConfig, GameConfig,
GameState, GameState,
OfficeArea, OfficeArea,
@@ -42,7 +43,7 @@ import type {
TrackCard, TrackCard,
TrayId, TrayId,
} from './state.ts'; } from './state.ts';
import { coordKey, freshTurn } from './state.ts'; import { coordKey, freshTurns } from './state.ts';
export type SetupOptions = { export type SetupOptions = {
id: string; 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 * branch from the opening Stage. The stubs are NOT turnouts: §A.1's directional rule governs
* drawn turnout cards only. * drawn turnout cards only.
*/ */
function buildOfficeArea(owner: PlayerIndex): OfficeArea { function buildOfficeArea(seat: SeatIndex): OfficeArea {
const row = 0; const row = 0;
const officeCoord = { row, col: 0 }; const officeCoord = { row, col: 0 };
const limitsWest = { row, col: -1 }; const limitsWest = { row, col: -1 };
@@ -159,7 +160,7 @@ function buildOfficeArea(owner: PlayerIndex): OfficeArea {
grid.set(coordKey(limitsEast), limitsCard()); grid.set(coordKey(limitsEast), limitsCard());
return { return {
owner, seat,
tier: 'whistlePost', tier: 'whistlePost',
grid, grid,
officeCoord, officeCoord,
@@ -230,7 +231,7 @@ function buildDivision(players: number, rng: Rng): DivisionNode[] {
nodes.push({ kind: 'divisionPoint', side: 'west', holding: [] }); nodes.push({ kind: 'divisionPoint', side: 'west', holding: [] });
for (let p = 0; p < players; p++) { for (let p = 0; p < players; p++) {
nodes.push(mainline()); nodes.push(mainline());
nodes.push({ kind: 'office', owner: p }); nodes.push({ kind: 'office', seat: p });
} }
nodes.push(mainline()); nodes.push(mainline());
nodes.push({ kind: 'divisionPoint', side: 'east', holding: [] }); 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 players = playerNames.map((name, index) => ({ index, name, revenue: 0 }));
const officeAreas = new Map<PlayerIndex, OfficeArea>(); // Offices are keyed by SEAT — a fixed position in the west-to-east chain. `seating` maps seats to
for (let p = 0; p < playerCount; p++) officeAreas.set(p, buildOfficeArea(p)); // 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<SeatIndex, OfficeArea>();
for (let seat = 0; seat < playerCount; seat++) officeAreas.set(seat, buildOfficeArea(seat));
const seating: PlayerIndex[] = Array.from({ length: playerCount }, (_, seat) => seat);
// §4.4 - highest D12 takes the Eastern Division Point; §4.5 - highest begins as Superintendent. // §4.4 - highest D12 takes the Eastern Division Point; §4.5 - highest begins as Superintendent.
// Both rolls are drawn even in solitaire so the RNG stream stays identical across player counts. // Both rolls are drawn even in solitaire so the RNG stream stays identical across player counts.
@@ -334,6 +339,7 @@ export function createGame(opts: SetupOptions): GameState {
seed, seed,
rngState: rng.getState(), rngState: rng.getState(),
players, players,
seating,
division: { nodes: buildDivision(playerCount, rng) }, division: { nodes: buildDivision(playerCount, rng) },
officeAreas, officeAreas,
trays: new Map(), trays: new Map(),
@@ -354,7 +360,7 @@ export function createGame(opts: SetupOptions): GameState {
superintendent, superintendent,
actorOffset: 0, actorOffset: 0,
}, },
turn: freshTurn(MOVES_PER_LOCAL_OPS), turns: freshTurns(playerCount, MOVES_PER_LOCAL_OPS),
movedThisPhase: new Set(), movedThisPhase: new Set(),
collisionsToday: 0, collisionsToday: 0,
status: 'active', status: 'active',
+76 -12
View File
@@ -24,6 +24,22 @@ import { officeProfile } from './content.ts';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export type PlayerIndex = number; 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 CardId = string;
export type TrayId = string; export type TrayId = string;
@@ -111,7 +127,8 @@ export type TrackCard = {
}; };
export type OfficeArea = { export type OfficeArea = {
owner: PlayerIndex; /** Where this Office sits in the chain, not who is sitting at it. See `SeatIndex`. */
seat: SeatIndex;
tier: OfficeTier; tier: OfficeTier;
grid: Map<string, TrackCard>; grid: Map<string, TrackCard>;
officeCoord: GridCoord; officeCoord: GridCoord;
@@ -223,7 +240,8 @@ export function isLockedByWork(card: TrackCard): boolean {
export type NodeRef = export type NodeRef =
| { at: 'divisionPoint'; side: Direction } | { at: 'divisionPoint'; side: Direction }
| { at: 'mainline'; index: number } | { 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 = { export type CrewTray = {
id: TrayId; id: TrayId;
@@ -328,7 +346,7 @@ export type DivisionNode =
/** Red Flags protecting a stopped train here, by tray. */ /** Red Flags protecting a stopped train here, by tray. */
redFlagged?: TrayId[]; 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. */ /** Ordered west to east. For N players: N Office nodes and N+1 Mainline cards. */
export type Division = { nodes: DivisionNode[] }; export type Division = { nodes: DivisionNode[] };
@@ -482,6 +500,30 @@ export type TurnState = {
done: boolean; 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<PlayerIndex, TurnState> {
const turns = new Map<PlayerIndex, TurnState>();
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 { export function freshTurn(moves: number): TurnState {
return { return {
option: null, option: null,
@@ -500,8 +542,15 @@ export type GameState = {
seed: number; seed: number;
rngState: number; rngState: number;
players: Player[]; 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; division: Division;
officeAreas: Map<PlayerIndex, OfficeArea>; officeAreas: Map<SeatIndex, OfficeArea>;
trays: Map<TrayId, CrewTray>; trays: Map<TrayId, CrewTray>;
/** Trays not yet in play; §7 scarcity is an explicit mechanic. */ /** Trays not yet in play; §7 scarcity is an explicit mechanic. */
freeTrays: TrayId[]; freeTrays: TrayId[];
@@ -521,7 +570,8 @@ export type GameState = {
*/ */
pendingSecondSections: number[]; pendingSecondSections: number[];
clock: Clock; clock: Clock;
turn: TurnState; /** One per player, keyed by PLAYER (a turn belongs to a person, not to a chair). */
turns: Map<PlayerIndex, TurnState>;
/** Transient: trains already moved in the current Mainline Phase. Cleared when it ends. */ /** Transient: trains already moved in the current Mainline Phase. Cleared when it ends. */
movedThisPhase: Set<TrayId>; movedThisPhase: Set<TrayId>;
/** §3.4 — resets at the start of each Day. */ /** §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) => { state.division.nodes.forEach((node, i) => {
const isBoundary = const isBoundary =
node.kind === 'divisionPoint' || node.kind === 'divisionPoint' ||
(node.kind === 'office' && isControlPoint(state, node.owner)); (node.kind === 'office' && isControlPoint(state, node.seat));
if (isBoundary) { if (isBoundary) {
if (current.length > 0) out.push(current); if (current.length > 0) out.push(current);
@@ -565,15 +615,29 @@ export function subdivisions(state: GameState): number[][] {
return out; return out;
} }
export function isControlPoint(state: GameState, owner: PlayerIndex): boolean { /** Who is sitting at this seat. */
const area = state.officeAreas.get(owner); export function playerAtSeat(state: GameState, seat: SeatIndex): PlayerIndex {
if (!area) throw new Error(`no Office Area for player ${owner}`); 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; return officeProfile(area.tier).isControlPoint;
} }
export function adTrackCount(state: GameState, owner: PlayerIndex): number { export function adTrackCount(state: GameState, seat: SeatIndex): number {
const area = state.officeAreas.get(owner); const area = state.officeAreas.get(seat);
if (!area) throw new Error(`no Office Area for player ${owner}`); if (!area) throw new Error(`no Office Area at seat ${seat}`);
return officeProfile(area.tier).adTracks; return officeProfile(area.tier).adTracks;
} }
+4 -3
View File
@@ -84,7 +84,8 @@ export function divisionSvg(nodes: DivisionView[]): string {
}[]; }[];
cap: number | null; cap: number | null;
tip: string; 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. */ /** Mainline cards only: §2.1 divides one into two regions. 0 elsewhere — no bars are drawn. */
regions: number; regions: number;
w: number; w: number;
@@ -120,7 +121,7 @@ export function divisionSvg(nodes: DivisionView[]): string {
: rc.trains, : rc.trains,
cap: isOffice ? cap : null, cap: isOffice ? cap : null,
tip: `${rc.label} — ${rc.kind === 'limits' ? 'the end of this district; the Running Track runs between the Limits' : 'Running Track'}`, 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 // 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. // occupies a card outright rather than a part of one.
regions: 0, regions: 0,
@@ -148,7 +149,7 @@ export function divisionSvg(nodes: DivisionView[]): string {
tip: dp 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' ? '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(' · ')}` : ''}`, : `${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. // A Division Point is one region — the queue trains enter and leave the Division through.
regions: dp ? 1 : (n.regions ?? 0), regions: dp ? 1 : (n.regions ?? 0),
w: dp ? CW.dp : CW.ml, w: dp ? CW.dp : CW.ml,
+18 -20
View File
@@ -27,7 +27,7 @@ import type { Intent } from '../engine/intents.ts';
import { legalActions } from '../engine/legal.ts'; import { legalActions } from '../engine/legal.ts';
import { connectionsFor, exitsFrom, facilityVariants, hasPort, joins, neighbour, opposite, variantsFor } from '../engine/track.ts'; import { connectionsFor, exitsFrom, facilityVariants, hasPort, joins, neighbour, opposite, variantsFor } from '../engine/track.ts';
import type { Port } 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'; import type { Facility, GameState, GridCoord, OfficeArea, PlayerIndex, RollingStock, TrackCard } from '../engine/state.ts';
export type BotPolicy = { 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.filter((i) => !(i.type === 'card.play' && isTrainCard(s, i.cardId)))
: options; : options;
const usable = held.length > 0 ? held : 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); 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. * 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` * 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. * `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, * 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 * 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. * 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 best: Intent | null = null;
let bestScore = -Infinity; let bestScore = -Infinity;
for (const i of options) { 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 // 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. // revenue, concentrating on the deepest 2.67, indifferent 2.67.
const top = topOfDepartment(s, i.toSlot); 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 depth = s.decks.departments[i.toSlot]?.length ?? 0;
const score = (top === undefined ? 6 : wanted ? -10 : 2) - Math.min(depth, 4) * 0.5; const score = (top === undefined ? 6 : wanted ? -10 : 2) - Math.min(depth, 4) * 0.5;
if (score > bestScore) { 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. */ /** A face-up card worth spending the draw on rather than gambling on the deck. */
function isWorthTaking(s: GameState, slot: number): boolean { function isWorthTaking(s: GameState, player: PlayerIndex, slot: number): boolean {
return takingRank(s, slot) > 0; 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 * happened to be scanned first — a coin flip on the card that decides whether the district ever
* becomes a Passenger Facility at all. * 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); const id = topOfDepartment(s, slot);
if (!id) return 0; if (!id) return 0;
const k = s.cards.get(id)?.kind; const k = s.cards.get(id)?.kind;
if (!k) return 0; 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 === 'timetabledTrain' || k.kind === 'extraTrain') return 2;
if (k.kind === 'freightFacility') return 1; if (k.kind === 'freightFacility') return 1;
return 0; return 0;
@@ -646,8 +646,7 @@ function runAroundCells(area: OfficeArea): Set<string> {
* there is one the crew dare not serve. * there is one the crew dare not serve.
*/ */
function bestFacilityPlay(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null { function bestFacilityPlay(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null {
const area = s.officeAreas.get(player); const area = areaOf(s, player);
if (!area) return null;
const onLoop = runAroundCells(area); const onLoop = runAroundCells(area);
const reachable = reachableOffMain(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 { function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null {
const area = s.officeAreas.get(player); const area = areaOf(s, player);
if (!area) return null;
const at = (row: number, col: number): TrackCard | undefined => area.grid.get(`${row},${col}`); const at = (row: number, col: number): TrackCard | undefined => area.grid.get(`${row},${col}`);
@@ -1125,17 +1123,17 @@ function followThrough(
options: Intent[], options: Intent[],
tweaks: BotTweaks, tweaks: BotTweaks,
): Intent { ): Intent {
switch (s.turn.option) { switch (turnOf(s, player).option) {
case 'draw': { case 'draw': {
// Draw before playing — otherwise the hand empties and never refills. // 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 // 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, // 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. // 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( const piles = options.filter(
(i): i is Extract<Intent, { type: 'draw.fromDepartment' }> => (i): i is Extract<Intent, { type: 'draw.fromDepartment' }> =>
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 // 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. // 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 // 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 // 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. // 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); 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'); 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); 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); if (end) return because('nothing in hand can be played anywhere legal', end);
return because( return because(
'nothing playable — discard onto the Department whose face-up card is least worth keeping reachable', '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) { function trayOf(s: GameState, player: PlayerIndex) {
for (const tray of s.trays.values()) { 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; return null;
} }
function trayLocation(s: GameState, player: PlayerIndex): { row: number; col: number } | null { function trayLocation(s: GameState, player: PlayerIndex): { row: number; col: number } | null {
for (const tray of s.trays.values()) { 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; return tray.position.coord;
} }
} }
+9 -9
View File
@@ -15,9 +15,9 @@
* panel cannot drift from the rules. * panel cannot drift from the rules.
*/ */
import { adTrackCount, coordKey } from '../engine/state.ts'; import { adTrackCount, coordKey, seatOf, turnOf } from '../engine/state.ts';
import type { GameState, GridCoord, RollingStock, TrayId } from '../engine/state.ts'; import type { GameState, GridCoord, PlayerIndex, RollingStock, TrayId } from '../engine/state.ts';
import { canAdvanceLoad, canStartLoad, facilityCarType, laborersLeft, movesFor, portersLeft } from '../engine/apply.ts'; import { areaOf, canAdvanceLoad, canStartLoad, facilityCarType, laborersLeft, movesFor, portersLeft } from '../engine/apply.ts';
import type { GameEvent } from '../engine/events.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 * 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. * 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 out: Impediment[] = [];
const area = s.officeAreas.get(player); const area = areaOf(s, player);
if (!area) return out;
for (const [key, card] of area.grid) { for (const [key, card] of area.grid) {
const f = card.facility; 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 * 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. * 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) { 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); const { blocked } = movesFor(s, player, id);
for (const b of blocked) { for (const b of blocked) {
// A turnout is not an obstruction — a train runs through one all day and simply may not // 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). // 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) { if (area.adOccupancy.length >= cap) {
out.push({ out.push({
where: 'Office', where: 'Office',
+2 -1
View File
@@ -25,6 +25,7 @@ import { writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { areaAtSeat } from '../engine/apply.ts';
import { legalActions } from '../engine/legal.ts'; import { legalActions } from '../engine/legal.ts';
import type { BotPolicy } from './bot.ts'; import type { BotPolicy } from './bot.ts';
import { developerBot, makeDeveloperBot } 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), save: toSave(game),
note: note:
`${revenue} Revenue over ${game.state.clock.day - 1} Days · ${trains} train(s) on the timetable · ` + `${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'), (collisions > 0 ? ` · ${collisions} collision(s)` : ' · no collisions'),
}; };
} }
+6 -3
View File
@@ -18,7 +18,7 @@
import type { GameEvent } from '../engine/events.ts'; import type { GameEvent } from '../engine/events.ts';
import type { Intent } from '../engine/intents.ts'; import type { Intent } from '../engine/intents.ts';
import type { GameState } from '../engine/state.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'; 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 // 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. // stuck, so this counts turns rather than trains.
for (const tray of s.trays.values()) { 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; if (tray.engineAt <= 0 || tray.engineAt >= tray.consist.length) continue;
funnel.buriedTurns += 1; funnel.buriedTurns += 1;
// Cars may never be set out at the Office (§A.4), which is where this almost always happens. // 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 grossPassenger = rev.passengerBoard + rev.passengerDetrain;
const gross = grossFreight + grossPassenger; 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; let freightFacilities = 0;
if (area) { if (area) {
for (const card of area.grid.values()) { for (const card of area.grid.values()) {
+57 -20
View File
@@ -11,6 +11,7 @@
*/ */
import { import {
areaAtSeat,
areaOf, areaOf,
destinationsFor, destinationsFor,
facilityCarType, facilityCarType,
@@ -40,6 +41,7 @@ import {
} from '../engine/content.ts'; } from '../engine/content.ts';
import type { Intent } from '../engine/intents.ts'; import type { Intent } from '../engine/intents.ts';
import type { Facility, GameState, PlayerIndex, TrackCard, TurnoutOrientation } from '../engine/state.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 { Hand, TrackGeometry } from '../engine/content.ts';
import type { Port } from '../engine/track.ts'; import type { Port } from '../engine/track.ts';
import { connectionsFor, slopeOfPair, variantsFor } 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. */ /** Office nodes only: the Running Track, Limits to Limits, west to east. */
running?: RunningCardView[]; running?: RunningCardView[];
/** Office nodes only: whose district this is. */ /** 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. * Office nodes only: crews working BELOW the Running Track.
* *
@@ -275,6 +278,21 @@ export type Frame = {
actor: number | null; actor: number | null;
superintendent: number; superintendent: number;
revenue: 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 }[]; lines: { text: string; tone: string }[];
where: { row: number; col: number } | null; where: { row: number; col: number } | null;
/** Origin of a Move, so the crew's journey is visible rather than a chip teleporting. */ /** 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 { export function describeIntent(s: GameState, i: Intent): string {
const at = (c: { row: number; col: number }): string => `(${c.row},${c.col})`; 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. // 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) { switch (i.type) {
case 'localOps.choose': case 'localOps.choose':
// The most consequential decision of the Stage, and it was labelled "choose switch". Say what // 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. * square was not empty. The Limits sign is excluded: laying track there is ordinary growth.
*/ */
const over = i.placement 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; : undefined;
const upgrade = over?.geometry.kind === 'track'; const upgrade = over?.geometry.kind === 'track';
return ( return (
@@ -616,7 +637,7 @@ export function describeIntent(s: GameState, i: Intent): string {
const here = tray?.position.at === 'grid' ? tray.position.coord : null; const here = tray?.position.at === 'grid' ? tray.position.coord : null;
let picks = ''; let picks = '';
if (here) { 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); .find((d) => d.coord.row === i.to.row && d.coord.col === i.to.col);
if (dest && dest.couples.length > 0) { if (dest && dest.couples.length > 0) {
picks = ` — couples ${carsLabel(dest.couples)} on the way${i.reverse ? ' (behind)' : ' (onto the nose)'}`; 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 * waiting for a train that can carry it. For a passenger facility that load is passengers on
* the platform. * 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'; const where = f?.kind === 'passenger' ? 'onto the platform' : 'into the green Loading box';
return i.carType === 'coach' && f?.kind === 'passenger' return i.carType === 'coach' && f?.kind === 'passenger'
? `bring passengers ${where} at ${at(i.at)} — they wait there for a train with an empty coach` ? `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, viewer: PlayerIndex = 0,
): Frame { ): Frame {
const area = areaOf(s, viewer); const area = areaOf(s, viewer);
const viewerSeat = seatOf(s, viewer);
const trayAt = new Map<string, string>(); const trayAt = new Map<string, string>();
for (const [id, tray] of s.trays) { 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 label = tray.trainNumber === null ? 'crew' : `T${tray.trainIsExtra ? 'X' : ''}${tray.trainNumber}`;
const carrying = tray.consist.length ? ` [${tray.consist.map(carLabel).join(', ')}]` : ' [empty]'; const carrying = tray.consist.length ? ` [${tray.consist.map(carLabel).join(', ')}]` : ' [empty]';
trayAt.set(`${tray.position.coord.row},${tray.position.coord.col}`, label + carrying); 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, 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. // Where every crew in this district actually is: on a Running Track card, or below it.
const onRunning = new Map<string, TrainChip[]>(); const onRunning = new Map<string, TrainChip[]>();
const below: TrainChip[] = []; const below: TrainChip[] = [];
for (const [id, tray] of s.trays) { for (const [id, tray] of s.trays) {
const pos = tray.position; 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); const c = trainChip(s, id);
if (pos.coord.row === oa.runningRow) { if (pos.coord.row === oa.runningRow) {
const k = `${pos.coord.row},${pos.coord.col}`; const k = `${pos.coord.row},${pos.coord.col}`;
@@ -954,7 +980,7 @@ export function snapshot(
capacity: officeProfile(oa.tier).adTracks, capacity: officeProfile(oa.tier).adTracks,
modifiers: [], modifiers: [],
gradeUp: null, gradeUp: null,
owner: n.owner, seat: n.seat,
running, running,
switching: below, switching: below,
}; };
@@ -968,7 +994,7 @@ export function snapshot(
phaseKey: s.clock.phase, phaseKey: s.clock.phase,
actor: s.clock.currentActor, actor: s.clock.currentActor,
superintendent: s.clock.superintendent, superintendent: s.clock.superintendent,
revenue: s.players[0]?.revenue ?? 0, revenue: s.players[viewer]?.revenue ?? 0,
lines, lines,
where, where,
whereFrom, whereFrom,
@@ -1011,11 +1037,21 @@ export function snapshot(
timetable: [...s.timetable], timetable: [...s.timetable],
decision, decision,
wasted, 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, runningRow: area.runningRow,
movesLeft: s.clock.phase === 'localOps' && s.turn.option === 'switch' ? s.turn.movesRemaining : null, movesLeft: s.clock.phase === 'localOps' && turnOf(s, viewer).option === 'switch' ? turnOf(s, viewer).movesRemaining : null,
moves: switchingMoves(s, 0), moves: switchingMoves(s, viewer),
blocked: impediments(s, 0), blocked: impediments(s, viewer),
trains: [...s.trays.values()].map((t) => ({ trains: [...s.trays.values()].map((t) => ({
label: t.trainNumber === null ? 'local crew' : `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`, label: t.trainNumber === null ? 'local crew' : `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
where: where:
@@ -1410,10 +1446,10 @@ const SIMPLE_CARDS = [
...ACTION_CARDS, ...ACTION_CARDS,
]; ];
/** The goal, and whether the current score is keeping up with the clock. */ /** The goal, and whether the VIEWER's score is keeping up with the clock. */
function objectiveOf(s: GameState): Frame['objective'] { function objectiveOf(s: GameState, viewer: PlayerIndex): Frame['objective'] {
const profile = lengthProfile(s.config.length); 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 daysLeft = Math.max(0, profile.days - s.clock.day + 1);
const elapsed = profile.days - daysLeft + 1; const elapsed = profile.days - daysLeft + 1;
// Straight-line pace: by the end of Day N you want N/days of the target. // 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. * question the page does not yet ask.
*/ */
function switchingMoves(s: GameState, player: PlayerIndex): Frame['moves'] { function switchingMoves(s: GameState, player: PlayerIndex): Frame['moves'] {
if (s.clock.phase !== 'localOps' || s.turn.option !== 'switch') return null; const turn = turnOf(s, player);
if (s.turn.movesRemaining < 1) return null; if (s.clock.phase !== 'localOps' || turn.option !== 'switch') return null;
if (turn.movesRemaining < 1) return null;
for (const [id, tray] of s.trays) { 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); const { to, blocked } = movesFor(s, player, id);
return { from: tray.position.coord, to, blocked }; return { from: tray.position.coord, to, blocked };
} }
+12 -3
View File
@@ -64,7 +64,14 @@ export const SOLO_CONFIG: GameConfig = {
export type ActionGroup = { export type ActionGroup = {
kind: string; kind: string;
title: 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 = { export type Game = {
@@ -175,13 +182,15 @@ export function actionGroups(game: Game): { options: Intent[]; groups: ActionGro
if (actor === null) return { options: [], groups: [] }; if (actor === null) return { options: [], groups: [] };
const options = legalActions(game.state, actor); const options = legalActions(game.state, actor);
const byKind = new Map<string, { index: number; label: string }[]>(); const byKind = new Map<string, { index: number; label: string; tip?: string }[]>();
options.forEach((intent, index) => { options.forEach((intent, index) => {
const label = describeIntent(game.state, intent); const label = describeIntent(game.state, intent);
const list = byKind.get(intent.type) ?? []; 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 // 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. // 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); byKind.set(intent.type, list);
}); });
+87 -76
View File
@@ -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 * 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. * 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 { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts';
import { TURNCHART_CSS, turnChartHtml } from '../sim/turnchart.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 { PANEL_CSS, blockedHtml, facilitiesHtml, pilesHtml, timetableHtml, yardHtml } from './panels.ts';
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts'; import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
import { playCue } from './sound.ts'; import { playCue } from './sound.ts';
import type { Game } from './game.ts';
import { MOVES_PER_LOCAL_OPS } from '../engine/content.ts'; import { MOVES_PER_LOCAL_OPS } from '../engine/content.ts';
import { cardDescription } from '../sim/view.ts'; import type { LocalSession } from './session.ts';
import { import { createLocalSession } from './session.ts';
actionMenu,
currentActor,
fromSave,
newGame,
submit,
toSave,
undo,
view,
} from './game.ts';
const SAVE_KEY = 'station-master.save.v1'; 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. */ /** Which card or track piece is picked, waiting for a location. */
let selected: string | null = null; 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 * 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. * are in the Day the same way and with the same violet highlight. It used to live here alone.
*/ */
function renderTurnChart(f: ReturnType<typeof view>): void { function renderTurnChart(f: Frame): void {
const actorName = f.actor === null ? null : (game.state.players[f.actor]?.name ?? null); const actorName = f.actor === null ? null : (f.players[f.actor]?.name ?? null);
$('turnchart').innerHTML = turnChartHtml(f, actorName); $('turnchart').innerHTML = turnChartHtml(f, actorName);
} }
@@ -117,23 +116,42 @@ function start(): void {
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
const requested = params.get('seed'); const requested = params.get('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. // 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); const seed = requested !== null ? Number(requested) || 1 : Math.floor(Math.random() * 1e9);
game = newGame(seed); session = createLocalSession(seed);
}
const saved = load();
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(); 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 { function render(): void {
const f = view(game); const f = session.view();
const menu = actionMenu(game); const menu = session.menu();
// Which squares the selected card or track piece may go on. Highlighting them is what turns the // 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. // coordinate list into a board: you pick the thing, then click where it goes.
@@ -166,7 +184,7 @@ function render(): void {
const obj = $('objective'); const obj = $('objective');
obj.textContent = `${f.revenue} of ${f.objective.target} · ${f.objective.daysLeft} Day${f.objective.daysLeft === 1 ? '' : 's'} left`; obj.textContent = `${f.revenue} of ${f.objective.target} · ${f.objective.daysLeft} Day${f.objective.daysLeft === 1 ? '' : 's'} left`;
obj.className = 'pace'; obj.className = 'pace';
$('seed').textContent = String(game.seed); $('seed').textContent = String(session.seed());
// -- division // -- division
$('division').innerHTML = divisionSvg(f.division); $('division').innerHTML = divisionSvg(f.division);
@@ -255,7 +273,7 @@ function render(): void {
function pick(key: string, list: { label: string; index: number }[]): void { function pick(key: string, list: { label: string; index: number }[]): void {
if (list.length === 1) { if (list.length === 1) {
const intent = menu.options[list[0]!.index]; const intent = menu.options[list[0]!.index];
if (intent) submit(game, intent); if (intent) void session.submit(intent);
selected = null; selected = null;
pendingAt = null; pendingAt = null;
} else { } 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 // 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 // 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". // 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 ( return (
`<div class="handcard${canPlay ? '' : ' unplayable'}${picked ? ' picked' : ''}${fresh ? ' fresh' : ''}"${fig}` + `<div class="handcard${canPlay ? '' : ' unplayable'}${picked ? ' picked' : ''}${fresh ? ' fresh' : ''}"${fig}` +
`${h.what ? ` data-tip="${esc(h.what)}"` : ''} tabindex="0">` + `${h.what ? ` data-tip="${esc(h.what)}"` : ''} tabindex="0">` +
@@ -341,7 +359,7 @@ function render(): void {
// A card that needs no square goes straight down; there is nothing to ask. // A card that needs no square goes straight down; there is nothing to ask.
if (verb === 'play' && entry.placeKey === null && entry.playNow !== null) { if (verb === 'play' && entry.placeKey === null && entry.playNow !== null) {
const intent = menu.options[entry.playNow]; const intent = menu.options[entry.playNow];
if (intent) submit(game, intent); if (intent) void session.submit(intent);
selected = null; selected = null;
mode = null; mode = null;
pendingAt = null; pendingAt = null;
@@ -374,7 +392,7 @@ function render(): void {
node.classList.add('target'); node.classList.add('target');
node.onclick = () => { node.onclick = () => {
const intent = menu.options[index]; const intent = menu.options[index];
if (intent) submit(game, intent); if (intent) void session.submit(intent);
selected = null; selected = null;
mode = null; mode = null;
render(); render();
@@ -397,7 +415,7 @@ function render(): void {
node.classList.add('addable'); node.classList.add('addable');
node.onclick = () => { node.onclick = () => {
const intent = menu.options[car.index]; const intent = menu.options[car.index];
if (intent) submit(game, intent); if (intent) void session.submit(intent);
render(); 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 — * `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". * it is gone by the next render, which is what makes it read as "that just happened".
*/ */
const justSet = game.scheduled; // Draining: the flash marks the moment the die was read, not a state, so it is gone by the next
game.scheduled = null; // render. Taken once here and handed to both the timetable and the action panel.
const justSet = session.takeScheduled();
$('timetable').innerHTML = timetableHtml(f, justSet); $('timetable').innerHTML = timetableHtml(f, justSet);
$('blocked').innerHTML = blockedHtml(f); $('blocked').innerHTML = blockedHtml(f);
// -- log // -- log
const log = $('log'); const log = $('log');
log.innerHTML = game.log log.innerHTML = session.lines()
.slice(-60) .slice(-60)
.map((l) => `<div class="line t-${l.tone}">${esc(l.text)}</div>`) .map((l) => `<div class="line t-${l.tone}">${esc(l.text)}</div>`)
.join(''); .join('');
@@ -446,7 +465,7 @@ function render(): void {
// Drain whatever the last batch of events earned. Cleared either way, so turning sound on does // 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. // 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); if (soundOn) for (const c of cues) playCue(c);
save(); save();
@@ -465,27 +484,20 @@ function render(): void {
*/ */
function renderUndo(): void { function renderUndo(): void {
const btn = document.getElementById('undo') as HTMLButtonElement | null; const btn = document.getElementById('undo') as HTMLButtonElement | null;
if (!btn) return; if (!btn || !session.capabilities.undo) return;
const n = game.history.length; const n = session.steps();
btn.disabled = n === 0; btn.disabled = n === 0;
btn.textContent = n === 0 ? 'Undo' : `Undo (${n})`; btn.textContent = n === 0 ? 'Undo' : `Undo (${n})`;
btn.onclick = () => { btn.onclick = () => {
const back = undo(game); // The session drops the rebuilt game's cues and draws — replaying the history re-records them,
if (!back) return; // and none of it is news to a player who just stepped back.
game = back; if (!session.undo()) return;
selected = null; selected = null;
mode = null; mode = null;
pendingAt = 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 // 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. // different phase is not that event, so the banner is suppressed rather than fired backwards.
lastPhase = null; 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 * 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. * left, and the moment the Division Yard empties a whole pile comes back at once.
*/ */
function renderYards(f: ReturnType<typeof view>): void { function renderYards(f: Frame): void {
$('divyard').innerHTML = yardHtml(f.yards.division); $('divyard').innerHTML = yardHtml(f.yards.division);
$('clsyard').innerHTML = yardHtml(f.yards.classification); $('clsyard').innerHTML = yardHtml(f.yards.classification);
$('divtot').textContent = `${f.yards.divisionTotal} cars`; $('divtot').textContent = `${f.yards.divisionTotal} cars`;
@@ -510,7 +522,7 @@ function renderYards(f: ReturnType<typeof view>): void {
: 'loaded / empty. Classification returns to the Division Yard only when the Division Yard is bare.'; : 'loaded / empty. Classification returns to the Division Yard only when the Division Yard is bare.';
} }
function renderDistrict(f: ReturnType<typeof view>): void { function renderDistrict(f: Frame): void {
const open = districtMode === 'auto' ? FOCUS_PHASES.has(f.phaseKey) : districtMode === 'open'; const open = districtMode === 'auto' ? FOCUS_PHASES.has(f.phaseKey) : districtMode === 'open';
const sec = $('district'); const sec = $('district');
if (open) sec.classList.remove('folded'); if (open) sec.classList.remove('folded');
@@ -539,18 +551,18 @@ function renderDistrict(f: ReturnType<typeof view>): void {
} }
function renderActions( function renderActions(
menu: ReturnType<typeof actionMenu>, menu: Menu,
f: ReturnType<typeof view>, f: Frame,
justSet: number | null, justSet: number | null,
): void { ): void {
const el = $('actions'); const el = $('actions');
if (game.state.status !== 'active') { if (f.status !== 'active') {
const o = game.state.outcome; const o = f.outcome;
el.innerHTML = el.innerHTML =
`<div class="over ${o?.result === 'win' ? 'win' : 'loss'}">` + `<div class="over ${o?.result === 'win' ? 'win' : 'loss'}">` +
`${o?.result === 'win' ? 'YOU WIN' : 'GAME OVER'} — ${esc(String(o?.reason ?? ''))}<br>` + `${o?.result === 'win' ? 'YOU WIN' : 'GAME OVER'} — ${esc(String(o?.reason ?? ''))}<br>` +
`final Revenue ${game.state.players[0]?.revenue ?? 0} against a target of ${view(game).objective.target}</div>` + `final Revenue ${f.revenue} against a target of ${f.objective.target}</div>` +
`<button id="again">new game</button>`; `<button id="again">new game</button>`;
$('again').onclick = () => { $('again').onclick = () => {
clearSave(); clearSave();
@@ -566,7 +578,7 @@ function renderActions(
const apply = (index: number): void => { const apply = (index: number): void => {
const intent = menu.options[index]; const intent = menu.options[index];
if (intent) submit(game, intent); if (intent) void session.submit(intent);
selected = null; selected = null;
mode = null; mode = null;
pendingAt = null; pendingAt = null;
@@ -582,15 +594,12 @@ function renderActions(
* the same card in hand explained itself perfectly — reported on "Realignment on Mainline card 3", * 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. * 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 cut = label.indexOf(' — ');
const head = cut > 0 ? label.slice(0, cut) : label; const head = cut > 0 ? label.slice(0, cut) : label;
let rest = cut > 0 ? label.slice(cut + 3) : ''; // The menu resolved the card's description server-side, so the page never needs the state.
if (!rest) { const rest = cut > 0 ? label.slice(cut + 3) : (a.tip ?? '');
const intent = menu.options[index];
const cardId = intent && 'cardId' in intent ? (intent as { cardId: string }).cardId : null;
if (cardId) rest = cardDescription(game.state, cardId);
}
return ( return (
`<button class="act" data-i="${index}"${rest ? ` data-tip="${esc(rest)}"` : ''}>${esc(head)}</button>` `<button class="act" data-i="${index}"${rest ? ` data-tip="${esc(rest)}"` : ''}>${esc(head)}</button>`
); );
@@ -644,7 +653,7 @@ function renderActions(
// §6.2 — a drawn card has to be played or discarded before the turn can end. Keyed on // §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 // the INTENT, not the label: matching button text would break the moment the wording
// changed, and would have caught `switch.end` too. // changed, and would have caught `switch.end` too.
return actionButton(a.label, a.index); return actionButton(a);
}) })
.join('') + .join('') +
`</div>`, `</div>`,
@@ -718,12 +727,12 @@ function renderActions(
html += `</div>`; html += `</div>`;
} }
if ( if (
game.state.clock.phase === 'localOps' && f.phaseKey === 'localOps' &&
game.state.turn.option === 'draw' && f.option === 'draw' &&
!menu.options.some((i) => i.type === 'draw.end') !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. // 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 += html +=
`<div class="grp"><button class="act blocked" disabled data-tip="§6.2 — you may not end a turn holding more than three cards (four with a Red Flag). Play one onto the board, or discard one face-up to a Department slot.">` + `<div class="grp"><button class="act blocked" disabled data-tip="§6.2 — you may not end a turn holding more than three cards (four with a Red Flag). Play one onto the board, or discard one face-up to a Department slot.">` +
`End Local Operations — play or discard down to three first (holding ${hand})</button></div>`; `End Local Operations — play or discard down to three first (holding ${hand})</button></div>`;
@@ -749,28 +758,29 @@ function renderActions(
* where a rendered page would have been megabytes. * where a rendered page would have been megabytes.
*/ */
function downloadSave(): void { 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 blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; 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(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
function save(): void { function save(): void {
if (!session.capabilities.saveLocal) return;
try { try {
localStorage.setItem(SAVE_KEY, JSON.stringify(toSave(game))); localStorage.setItem(SAVE_KEY, JSON.stringify(session.save()));
} catch { } catch {
// A full or disabled localStorage must not take the game down with it. // A full or disabled localStorage must not take the game down with it.
} }
} }
function load(): ReturnType<typeof toSave> | null { function load(): Save | null {
try { try {
const raw = localStorage.getItem(SAVE_KEY); const raw = localStorage.getItem(SAVE_KEY);
return raw ? (JSON.parse(raw) as ReturnType<typeof toSave>) : null; return raw ? (JSON.parse(raw) as Save) : null;
} catch { } catch {
return null; return null;
} }
@@ -810,9 +820,10 @@ if (saveBtn) saveBtn.onclick = downloadSave;
const newBtn = document.getElementById('newgame'); const newBtn = document.getElementById('newgame');
if (newBtn) { if (newBtn) {
newBtn.onclick = () => { newBtn.onclick = () => {
const day = game.state.clock.day; const f = session.view();
const started = game.state.status === 'active' && (day > 1 || game.state.clock.stage > 1); const day = f.day;
if (started && !confirm(`Forget this game (seed ${game.seed}, Day ${day}) and deal a new one?`)) return; 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. * ASK FOR THE SEED, rather than documenting a URL parameter in the title bar.
* *
+172
View File
@@ -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<boolean>;
/** 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();
},
};
}
+1 -1
View File
@@ -586,7 +586,7 @@ describe('X18 Circus Train — a point for standing still', () => {
s.trays.set('circus', { s.trays.set('circus', {
id: 'circus', trainNumber: 18, trainIsExtra: true, engineAt: 0, id: 'circus', trainNumber: 18, trainIsExtra: true, engineAt: 0,
consist: [], direction: 'east', 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, movesUsed: 0,
} as never); } as never);
// A card under it, so the crew is somewhere real rather than off the grid. // A card under it, so the crew is somewhere real rather than off the grid.
+9 -9
View File
@@ -12,7 +12,7 @@ import type { Intent } from '../src/engine/intents.ts';
import { legalActions } from '../src/engine/legal.ts'; import { legalActions } from '../src/engine/legal.ts';
import { createGame } from '../src/engine/setup.ts'; import { createGame } from '../src/engine/setup.ts';
import type { CrewTray, GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.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'; import { cardDescription, snapshot } from '../src/sim/view.ts';
const config: GameConfig = { const config: GameConfig = {
@@ -42,7 +42,7 @@ function placeTray(s: GameState, coord: GridCoord, consist: CrewTray['consist']
engineAt: 0, engineAt: 0,
consist, consist,
direction: 'east', direction: 'east',
position: { at: 'grid', owner: 0, coord }, position: { at: 'grid', seat: 0, coord },
movesUsed: 0, movesUsed: 0,
}); });
return id; return id;
@@ -90,7 +90,7 @@ describe('Local Operations: the three-way exclusive choice (§6)', () => {
const s = game(); const s = game();
const r = applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); const r = applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
assert.ok(r.ok); 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', () => { 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' }); applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
const r = applyIntent(s, 0, { type: 'switch.move', trayId: tray, to: at(0, 2), reverse: false }); const r = applyIntent(s, 0, { type: 'switch.move', trayId: tray, to: at(0, 2), reverse: false });
assert.ok(r.ok); 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', () => { it('couples standing cars automatically and mandatorily', () => {
@@ -395,7 +395,7 @@ describe('Local Operations: switching (§6.1, Appendix A)', () => {
const s = game(); const s = game();
const tray = placeTray(s, at(0, 1)); const tray = placeTray(s, at(0, 1));
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' }); applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
s.turn.movesRemaining = 0; turnOf(s, 0).movesRemaining = 0;
assert.equal( assert.equal(
check(s, 0, { type: 'switch.move', trayId: tray, to: at(0, 0), reverse: false }), check(s, 0, { type: 'switch.move', trayId: tray, to: at(0, 0), reverse: false }),
'NO_MOVES_REMAINING', '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.phase = 'localOps';
s.clock.currentActor = 0; s.clock.currentActor = 0;
s.turn.option = 'draw'; turnOf(s, 0).option = 'draw';
return at(area.runningRow - 1, col + 1); 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.trays.get(id)!.engineAt = 1;
s.clock.phase = 'localOps'; s.clock.phase = 'localOps';
s.clock.currentActor = 0; 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. // 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, fromNose: true }), null);
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: id, count: 1 }), 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 // 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. // in your own district: cars appeared on a train nobody was making up.
const s = game(); 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'); 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) { for (const [id, card] of s.cards) {
if (card.kind.kind !== 'office' || card.kind.tier !== tier) continue; if (card.kind.kind !== 'office' || card.kind.tier !== tier) continue;
s.decks.hands.set(0, [id]); s.decks.hands.set(0, [id]);
s.turn.option = null; turnOf(s, 0).option = null;
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const r = applyIntent(s, 0, { type: 'card.play', cardId: id }); const r = applyIntent(s, 0, { type: 'card.play', cardId: id });
assert.ok(r.ok, `${tier} upgrade should apply`); assert.ok(r.ok, `${tier} upgrade should apply`);
+5 -5
View File
@@ -14,7 +14,7 @@ import { applyIntent, areaOf, check, hasDistrictEnhancement, isProtectedFromDera
import { ENHANCEMENT_RULES, enhancementRule, trainProfile } from '../src/engine/content.ts'; import { ENHANCEMENT_RULES, enhancementRule, trainProfile } from '../src/engine/content.ts';
import { createGame } from '../src/engine/setup.ts'; import { createGame } from '../src/engine/setup.ts';
import type { GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.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 = { const config: GameConfig = {
mode: 'solitaire', mode: 'solitaire',
@@ -65,7 +65,7 @@ function placeTray(s: GameState, coord: GridCoord, consist: TrackCard['standing'
const id = s.freeTrays.pop()!; const id = s.freeTrays.pop()!;
s.trays.set(id, { s.trays.set(id, {
id, trainNumber: 9, trainIsExtra: false, engineAt: 0, 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; 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"', () => { it('costs one Move — "spends one move in the yard"', () => {
const { s, tray } = yardGame(); 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] }); 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', () => { 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'; const id = 'leaving';
s.trays.set(id, { s.trays.set(id, {
id, trainNumber: 2, trainIsExtra: false, engineAt: 0, 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, movesUsed: 0,
}); });
areaOf(s, 0).adOccupancy.push(id); areaOf(s, 0).adOccupancy.push(id);
+16 -16
View File
@@ -27,7 +27,7 @@ import {
import { createGame } from '../src/engine/setup.ts'; import { createGame } from '../src/engine/setup.ts';
import type { GameEvent } from '../src/engine/events.ts'; import type { GameEvent } from '../src/engine/events.ts';
import type { GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.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'; import { snapshot } from '../src/sim/view.ts';
const config: GameConfig = { const config: GameConfig = {
@@ -81,8 +81,8 @@ function pinned(s: GameState, index: number, card: string) {
function drawTurn(s: GameState): void { function drawTurn(s: GameState): void {
s.clock.phase = 'localOps'; s.clock.phase = 'localOps';
s.clock.currentActor = 0; s.clock.currentActor = 0;
s.turn.option = 'draw'; turnOf(s, 0).option = 'draw';
s.turn.drawnThisTurn = true; 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, id: 'crew', trainNumber: 1, trainIsExtra: false, engineAt: 0,
consist: [{ type: 'hopper', loaded: false }, { type: 'boxcar', loaded: false }], consist: [{ type: 'hopper', loaded: false }, { type: 'boxcar', loaded: false }],
direction: 'east', facing: 'e', 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.phase = 'localOps';
s.clock.currentActor = 0; s.clock.currentActor = 0;
s.turn.option = 'switch'; turnOf(s, 0).option = 'switch';
s.turn.movesRemaining = 6; turnOf(s, 0).movesRemaining = 6;
return industry; 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.equal(s.trays.get('crew')!.consist.length, 1, 'the cut leaves the consist');
assert.deepEqual( assert.deepEqual(
s.trays.get('crew')!.position, 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', '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', () => { 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. // — track on the far side of the sign, and a second sign planted beyond that.
const s = game(); const s = game();
const area = areaOf(s, 0); const area = areaOf(s, 0);
s.turn.option = 'draw'; turnOf(s, 0).option = 'draw';
const straight = trackInHand(s, 'straight', 'none'); const straight = trackInHand(s, 'straight', 'none');
const beyondEast = { row: area.runningRow, col: area.limitsEast.col + 1 }; 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. // player's Limits is what makes a collision his fault.
const s = game(); const s = game();
const area = areaOf(s, 0); const area = areaOf(s, 0);
s.turn.option = 'draw'; turnOf(s, 0).option = 'draw';
for (let n = 0; n < 4; n++) { for (let n = 0; n < 4; n++) {
const target = { row: area.runningRow, col: area.limitsWest.col }; 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 // 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. // have been a turnout when it went down.
const s = game(); const s = game();
s.turn.option = 'draw'; turnOf(s, 0).option = 'draw';
const at = layStraight(s); const at = layStraight(s);
for (const hand of ['left', 'right'] as const) { 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. // opposite edges, so swapping one for the other would move the leg off whatever it joined.
const s = game(); const s = game();
const area = areaOf(s, 0); 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. // 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 }; 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', () => { it('refuses to swap the track out from under a car, or out from under an Interlocking', () => {
const s = game(); const s = game();
const area = areaOf(s, 0); const area = areaOf(s, 0);
s.turn.option = 'draw'; turnOf(s, 0).option = 'draw';
const at = layStraight(s); const at = layStraight(s);
const square = area.grid.get(`${at.row},${at.col}`)!; 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. // The Office is east-west track like a straight, but it is not track to build over.
const s = game(); const s = game();
const area = areaOf(s, 0); const area = areaOf(s, 0);
s.turn.option = 'draw'; turnOf(s, 0).option = 'draw';
assert.equal( assert.equal(
check(s, 0, { check(s, 0, {
type: 'card.play', cardId: trackInHand(s, 'turnout', 'right'), 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', () => { it('does not let a straight or a curve upgrade anything — only a turnout may', () => {
const s = game(); const s = game();
s.turn.option = 'draw'; turnOf(s, 0).option = 'draw';
const at = layStraight(s); const at = layStraight(s);
assert.equal( 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 // 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. // allowed the play and `legalIntents` never once offered a square it could go on.
const s = game(); const s = game();
s.turn.option = 'draw'; turnOf(s, 0).option = 'draw';
const at = layStraight(s); const at = layStraight(s);
const cardId = trackInHand(s, 'turnout', 'right'); const cardId = trackInHand(s, 'turnout', 'right');
+215 -4
View File
@@ -15,9 +15,12 @@ import { areaOf } from '../src/engine/apply.ts';
import { STAGES_PER_SHIFT, crewTrayCount } from '../src/engine/content.ts'; import { STAGES_PER_SHIFT, crewTrayCount } from '../src/engine/content.ts';
import { createGame } from '../src/engine/setup.ts'; import { createGame } from '../src/engine/setup.ts';
import type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.ts'; import type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.ts';
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 { developerBot, playGame } from '../src/sim/bot.ts';
import { snapshot } from '../src/sim/view.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 = { const competitive: GameConfig = {
mode: 'competitive', mode: 'competitive',
@@ -39,11 +42,34 @@ function readyToLeave(s: GameState, owner: PlayerIndex, id: string, direction: '
const area = areaOf(s, owner); const area = areaOf(s, owner);
s.trays.set(id, { s.trays.set(id, {
id, trainNumber: 9, trainIsExtra: false, engineAt: 0, consist: [], id, trainNumber: 9, trainIsExtra: false, engineAt: 0, consist: [],
direction, position: { at: 'grid', owner, coord: area.officeCoord }, movesUsed: 0, direction, position: { at: 'grid', seat: owner, coord: area.officeCoord }, movesUsed: 0,
}); });
area.adOccupancy.push(id); 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 => { const pinTerrain = (s: GameState): void => {
// Double Track and Uncontrolled Siding print "trains may pass", which would clear any departure. // 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'; 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); const s = game(players);
assert.equal(s.officeAreas.size, players, `${players}p office areas`); assert.equal(s.officeAreas.size, players, `${players}p office areas`);
for (let p = 0; p < players; p++) { 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. // §7 — trays are scarce on purpose, and the count is per player count.
assert.equal(s.freeTrays.length, crewTrayCount(players), `${players}p crew trays`); assert.equal(s.freeTrays.length, crewTrayCount(players), `${players}p crew trays`);
@@ -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. // The upgraded Office is a BOUNDARY, so it appears in neither group; the others still sit inside.
const officeIndex = (owner: number): number => 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(); const all = split.flat();
assert.ok(!all.includes(officeIndex(1)), 'the Control Point is still inside a Subdivision'); assert.ok(!all.includes(officeIndex(1)), 'the Control Point is still inside a Subdivision');
for (const other of [0, 2, 3]) { for (const other of [0, 2, 3]) {
@@ -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', () => { describe('the view shows one seat at a time', () => {
it('gives each seat its own board and its own hand', () => { 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'); 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', () => { it('defaults to seat 0, so solitaire and every replay are unaffected', () => {
const s = game(2); const s = game(2);
assert.deepEqual( assert.deepEqual(
+232
View File
@@ -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<typeof createLocalSession>): Promise<boolean> {
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,
});
});
});
+4 -4
View File
@@ -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 { GameConfig, GameState, OfficeArea } from '../src/engine/state.ts';
import type { Intent } from '../src/engine/intents.ts'; import type { Intent } from '../src/engine/intents.ts';
import { connectionsFor, exitsFrom, hasPort, joins, neighbour, opposite, variantsFor } from '../src/engine/track.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 type { Port } from '../src/engine/track.ts';
import { developerBot, playGame, randomBot } from '../src/sim/bot.ts'; import { developerBot, playGame, randomBot } from '../src/sim/bot.ts';
import { simulate } from '../src/sim/harness.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.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`); assert.equal(tray.position.at, 'grid', `${id} is recorded at an Office but is elsewhere`);
if (tray.position.at === 'grid') { 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)!; const area = s.officeAreas.get(0)!;
s.clock.phase = 'localOps'; s.clock.phase = 'localOps';
s.clock.currentActor = 0; 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 // 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 // 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. // `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); } as never);
s.clock.phase = 'localOps'; s.clock.phase = 'localOps';
s.clock.currentActor = 0; s.clock.currentActor = 0;
s.turn.option = 'freightAgent'; turnOf(s, 0).option = 'freightAgent';
const jammed = area.grid.get('-1,0')!; const jammed = area.grid.get('-1,0')!;
assert.ok(!isOperationalRail(jammed), 'a jammed industry is not Operational Rail'); assert.ok(!isOperationalRail(jammed), 'a jammed industry is not Operational Rail');
+8 -8
View File
@@ -15,7 +15,7 @@ import type {
TrackCard, TrackCard,
TurnoutOrientation, TurnoutOrientation,
} from '../src/engine/state.ts'; } 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 { createGame } from '../src/engine/setup.ts';
import { applyIntent, areaOf } from '../src/engine/apply.ts'; import { applyIntent, areaOf } from '../src/engine/apply.ts';
import type { MoveContext, Occupancy, Port } from '../src/engine/track.ts'; import type { MoveContext, Occupancy, Port } from '../src/engine/track.ts';
@@ -134,7 +134,7 @@ function areaFrom(cards: Record<string, TrackCard>, officeCoord: GridCoord): Off
const grid = new Map<string, TrackCard>(); const grid = new Map<string, TrackCard>();
for (const [k, v] of Object.entries(cards)) grid.set(k, v); for (const [k, v] of Object.entries(cards)) grid.set(k, v);
return { return {
owner: 0, seat: 0,
tier: 'whistlePost', tier: 'whistlePost',
grid, grid,
officeCoord, 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, // 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. // 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: [], 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.phase = 'localOps';
s.clock.currentActor = 0; s.clock.currentActor = 0;
s.turn.option = 'switch'; turnOf(s, 0).option = 'switch';
s.turn.movesRemaining = 6; turnOf(s, 0).movesRemaining = 6;
const r = applyIntent(s, 0, { type: 'switch.move', trayId: 'crew', to: { row: 0, col: -1 }, reverse: false }); 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'); 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, { s.trays.set(id, {
id, trainNumber: null, trainIsExtra: false, engineAt: 0, id, trainNumber: null, trainIsExtra: false, engineAt: 0,
consist: [], direction: 'east', facing: 'e', 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); } as never);
s.clock.phase = 'localOps'; s.clock.phase = 'localOps';
s.clock.currentActor = 0; s.clock.currentActor = 0;
s.turn.option = 'switch'; turnOf(s, 0).option = 'switch';
s.turn.movesRemaining = 6; turnOf(s, 0).movesRemaining = 6;
// Running forward, east: the engine leads, so it still points east. // 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); assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 2), reverse: false }).ok);
+5 -5
View File
@@ -14,7 +14,7 @@ import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
import { ALL_TRAINS, trainProfile } from '../src/engine/content.ts'; import { ALL_TRAINS, trainProfile } from '../src/engine/content.ts';
import { createGame } from '../src/engine/setup.ts'; import { createGame } from '../src/engine/setup.ts';
import type { GameConfig, GameState, GridCoord, RollingStock, TrackCard } from '../src/engine/state.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'; import { trainRules } from '../src/sim/view.ts';
const config: GameConfig = { const config: GameConfig = {
@@ -54,12 +54,12 @@ function switching(
area.grid.set(coordKey({ row: office.row, col: office.col - 3 }), straight()); area.grid.set(coordKey({ row: office.row, col: office.col - 3 }), straight());
s.trays.set('t', { s.trays.set('t', {
id: 't', trainNumber, trainIsExtra: isExtra, engineAt: 0, consist, 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.phase = 'localOps';
s.clock.currentActor = 0; s.clock.currentActor = 0;
s.turn.option = 'switch'; turnOf(s, 0).option = 'switch';
s.turn.movesRemaining = 6; turnOf(s, 0).movesRemaining = 6;
return 't'; return 't';
} }
@@ -210,7 +210,7 @@ describe('§7 — trains Porters may not work', () => {
}; };
s.trays.set('t', { s.trays.set('t', {
id: 't', trainNumber, trainIsExtra: isExtra, engineAt: 0, consist: [coach(false)], 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'); area.adOccupancy.push('t');
s.clock.phase = 'loadUnload'; s.clock.phase = 'loadUnload';
+26 -4
View File
@@ -7,6 +7,7 @@
import { describe, it } from 'node:test'; import { describe, it } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { turnOf } from '../src/engine/state.ts';
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path'; 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', () => { 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. // 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. // 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 }, { type: 'caboose', loaded: false },
], ],
direction: 'east', facing: 'e', 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); } as never);
const cell = view(game).cells.find((c) => c.row === area.runningRow && c.col === 0)!; 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 }, { type: 'caboose', loaded: false },
], ],
direction: facing === 'e' ? 'east' : 'west', facing, 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); } as never);
const cell = view(game).cells.find((c) => c.row === area.runningRow && c.col === 0)!; const cell = view(game).cells.find((c) => c.row === area.runningRow && c.col === 0)!;
const svg = officeSvg([cell], area.runningRow); const svg = officeSvg([cell], area.runningRow);
@@ -1899,7 +1921,7 @@ describe('the static build', () => {
id, trainNumber: null, trainIsExtra: false, engineAt: 1, id, trainNumber: null, trainIsExtra: false, engineAt: 1,
consist: [{ type: 'boxcar', loaded: true }, { type: 'caboose', loaded: false }], consist: [{ type: 'boxcar', loaded: true }, { type: 'caboose', loaded: false }],
direction: 'east', facing: 'e', 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); } as never);
const front = describeIntent(game.state, { type: 'switch.dropCars', trayId: id, count: 1, fromNose: true }); 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 }); 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; if (currentActor(game) === null) break;
const { options, groups } = actionGroups(game); const { options, groups } = actionGroups(game);
if (groups.length === 0 || options.length === 0) break; 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'); const engineAllows = options.some((o) => o.type === 'draw.end');
assert.equal( assert.equal(
engineAllows, engineAllows,