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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
+83
-214
@@ -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 }
|
floor that ends the game.
|
||||||
TrayMoved { trayId, from, to, movesRemaining }
|
- **Events are not what goes over the wire.** The server folds them and pushes the resulting `Frame`
|
||||||
CarsCoupled { trayId, stock[] } -- automatic, from a Move
|
(`multiplayer.md` D2/D3). They are the store and the replay format, not the protocol.
|
||||||
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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
@@ -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`);
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user